@zeix/cause-effect 0.18.5 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +2 -1
- package/.zed/settings.json +24 -1
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +23 -0
- package/README.md +42 -1
- package/REQUIREMENTS.md +3 -3
- package/bench/reactivity.bench.ts +18 -7
- package/biome.json +1 -1
- package/eslint.config.js +2 -1
- package/index.dev.js +11 -5
- package/index.js +1 -1
- package/index.ts +1 -1
- package/package.json +6 -6
- package/skills/cause-effect/SKILL.md +69 -0
- package/skills/cause-effect/agents/openai.yaml +4 -0
- package/skills/cause-effect/references/api-facts.md +179 -0
- package/skills/cause-effect/references/error-classes.md +153 -0
- package/skills/cause-effect/references/non-obvious-behaviors.md +173 -0
- package/skills/cause-effect/references/signal-types.md +288 -0
- package/skills/cause-effect/workflows/answer-question.md +54 -0
- package/skills/cause-effect/workflows/debug.md +71 -0
- package/skills/cause-effect/workflows/use-api.md +63 -0
- package/skills/cause-effect-dev/SKILL.md +75 -0
- package/skills/cause-effect-dev/agents/openai.yaml +4 -0
- package/skills/cause-effect-dev/references/api-facts.md +96 -0
- package/skills/cause-effect-dev/references/error-classes.md +97 -0
- package/skills/cause-effect-dev/references/internal-types.md +54 -0
- package/skills/cause-effect-dev/references/non-obvious-behaviors.md +146 -0
- package/skills/cause-effect-dev/references/source-map.md +45 -0
- package/skills/cause-effect-dev/workflows/answer-question.md +55 -0
- package/skills/cause-effect-dev/workflows/fix-bug.md +63 -0
- package/skills/cause-effect-dev/workflows/implement-feature.md +46 -0
- package/skills/cause-effect-dev/workflows/write-tests.md +64 -0
- package/skills/changelog-keeper/SKILL.md +47 -37
- package/skills/tech-writer/SKILL.md +94 -0
- package/skills/tech-writer/references/document-map.md +199 -0
- package/skills/tech-writer/references/tone-guide.md +189 -0
- package/skills/tech-writer/workflows/consistency-review.md +98 -0
- package/skills/tech-writer/workflows/update-after-change.md +65 -0
- package/skills/tech-writer/workflows/update-agent-docs.md +77 -0
- package/skills/tech-writer/workflows/update-architecture.md +61 -0
- package/skills/tech-writer/workflows/update-jsdoc.md +72 -0
- package/skills/tech-writer/workflows/update-public-api.md +59 -0
- package/skills/tech-writer/workflows/update-requirements.md +80 -0
- package/src/graph.ts +8 -4
- package/src/nodes/collection.ts +42 -2
- package/src/nodes/effect.ts +13 -1
- package/src/nodes/list.ts +28 -4
- package/src/nodes/memo.ts +0 -1
- package/src/nodes/sensor.ts +10 -4
- package/src/nodes/store.ts +11 -0
- package/src/signal.ts +6 -0
- package/test/benchmark.test.ts +25 -11
- package/test/collection.test.ts +6 -3
- package/test/effect.test.ts +2 -1
- package/test/list.test.ts +8 -4
- package/test/regression.test.ts +4 -2
- package/test/store.test.ts +8 -4
- package/test/util/dependency-graph.ts +12 -6
- package/tsconfig.json +14 -1
- package/types/index.d.ts +1 -1
- package/types/src/graph.d.ts +2 -2
- package/OWNERSHIP_BUG.md +0 -95
package/src/nodes/effect.ts
CHANGED
|
@@ -20,13 +20,22 @@ import {
|
|
|
20
20
|
|
|
21
21
|
/* === Types === */
|
|
22
22
|
|
|
23
|
+
/** A value that is either synchronous or a `Promise` — used for handler return types in `match()`. */
|
|
23
24
|
type MaybePromise<T> = T | Promise<T>
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Handlers for all states of one or more signals passed to `match()`.
|
|
28
|
+
*
|
|
29
|
+
* @template T - Tuple of `Signal` types being matched
|
|
30
|
+
*/
|
|
25
31
|
type MatchHandlers<T extends readonly Signal<unknown & {}>[]> = {
|
|
32
|
+
/** Called when all signals have a value. Receives a tuple of resolved values. */
|
|
26
33
|
ok: (values: {
|
|
27
34
|
[K in keyof T]: T[K] extends Signal<infer V> ? V : never
|
|
28
35
|
}) => MaybePromise<MaybeCleanup>
|
|
36
|
+
/** Called when one or more signals hold an error. Defaults to `console.error`. */
|
|
29
37
|
err?: (errors: readonly Error[]) => MaybePromise<MaybeCleanup>
|
|
38
|
+
/** Called when one or more signals are unset (pending). */
|
|
30
39
|
nil?: () => MaybePromise<MaybeCleanup>
|
|
31
40
|
}
|
|
32
41
|
|
|
@@ -88,10 +97,13 @@ function createEffect(fn: EffectCallback): Cleanup {
|
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/**
|
|
91
|
-
*
|
|
100
|
+
* Reads one or more signals and dispatches to the appropriate handler based on their state.
|
|
92
101
|
* Must be called within an active owner (effect or scope) so async cleanup can be registered.
|
|
93
102
|
*
|
|
94
103
|
* @since 0.15.0
|
|
104
|
+
* @param signals - Tuple of signals to read; all must have a value for `ok` to run.
|
|
105
|
+
* @param handlers - Object with an `ok` branch and optional `err` and `nil` branches.
|
|
106
|
+
* @returns An optional cleanup function if the active handler returns one.
|
|
95
107
|
* @throws RequiredOwnerError If called without an active owner.
|
|
96
108
|
*/
|
|
97
109
|
function match<T extends readonly Signal<unknown & {}>[]>(
|
package/src/nodes/list.ts
CHANGED
|
@@ -40,13 +40,33 @@ type DiffResult = {
|
|
|
40
40
|
remove: UnknownRecord
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Key generation strategy for `createList` items.
|
|
45
|
+
* A string value is used as a prefix for auto-incremented keys (`prefix0`, `prefix1`, …).
|
|
46
|
+
* A function receives each item and returns a stable string key, or `undefined` to fall back to auto-increment.
|
|
47
|
+
*
|
|
48
|
+
* @template T - The type of items in the list
|
|
49
|
+
*/
|
|
43
50
|
type KeyConfig<T> = string | ((item: T) => string | undefined)
|
|
44
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Configuration options for `createList`.
|
|
54
|
+
*
|
|
55
|
+
* @template T - The type of items in the list
|
|
56
|
+
*/
|
|
45
57
|
type ListOptions<T extends {}> = {
|
|
58
|
+
/** Key generation strategy. A string prefix or a function `(item) => string | undefined`. Defaults to auto-increment. */
|
|
46
59
|
keyConfig?: KeyConfig<T>
|
|
60
|
+
/** Lifecycle callback invoked when the list gains its first downstream subscriber. Must return a cleanup function. */
|
|
47
61
|
watched?: () => Cleanup
|
|
48
62
|
}
|
|
49
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A reactive ordered array with stable keys and per-item reactivity.
|
|
66
|
+
* Each item is a `State<T>` signal; structural changes (add/remove/sort) propagate reactively.
|
|
67
|
+
*
|
|
68
|
+
* @template T - The type of items in the list
|
|
69
|
+
*/
|
|
50
70
|
type List<T extends {}> = {
|
|
51
71
|
readonly [Symbol.toStringTag]: 'List'
|
|
52
72
|
readonly [Symbol.isConcatSpreadable]: true
|
|
@@ -189,7 +209,8 @@ function diffArrays<T>(
|
|
|
189
209
|
const prevByKey = new Map<string, T>()
|
|
190
210
|
for (let i = 0; i < prev.length; i++) {
|
|
191
211
|
const key = prevKeys[i]
|
|
192
|
-
|
|
212
|
+
const item = prev[i]
|
|
213
|
+
if (key && item !== undefined) prevByKey.set(key, item)
|
|
193
214
|
}
|
|
194
215
|
|
|
195
216
|
// Track which old keys we've seen
|
|
@@ -239,8 +260,9 @@ function diffArrays<T>(
|
|
|
239
260
|
*
|
|
240
261
|
* @since 0.18.0
|
|
241
262
|
* @param value - Initial array of items
|
|
242
|
-
* @param options -
|
|
243
|
-
* @
|
|
263
|
+
* @param options.keyConfig - Key generation strategy: string prefix or `(item) => string | undefined`. Defaults to auto-increment.
|
|
264
|
+
* @param options.watched - Lifecycle callback invoked on first subscriber; must return a cleanup function called on last unsubscribe.
|
|
265
|
+
* @returns A `List` signal with reactive per-item `State` signals
|
|
244
266
|
*/
|
|
245
267
|
function createList<T extends {}>(
|
|
246
268
|
value: T[],
|
|
@@ -422,7 +444,8 @@ function createList<T extends {}>(
|
|
|
422
444
|
},
|
|
423
445
|
|
|
424
446
|
at(index: number) {
|
|
425
|
-
|
|
447
|
+
const key = keys[index]
|
|
448
|
+
return key !== undefined ? signals.get(key) : undefined
|
|
426
449
|
},
|
|
427
450
|
|
|
428
451
|
keys() {
|
|
@@ -458,6 +481,7 @@ function createList<T extends {}>(
|
|
|
458
481
|
remove(keyOrIndex: string | number) {
|
|
459
482
|
const key =
|
|
460
483
|
typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex
|
|
484
|
+
if (key === undefined) return
|
|
461
485
|
const ok = signals.delete(key)
|
|
462
486
|
if (ok) {
|
|
463
487
|
const index =
|
package/src/nodes/memo.ts
CHANGED
|
@@ -36,7 +36,6 @@ type Memo<T extends {}> = {
|
|
|
36
36
|
* Recomputes if dependencies have changed since last access.
|
|
37
37
|
* When called inside another reactive context, creates a dependency.
|
|
38
38
|
* @returns The computed value
|
|
39
|
-
* @throws UnsetSignalValueError If the memo value is still unset when read.
|
|
40
39
|
*/
|
|
41
40
|
get(): T
|
|
42
41
|
}
|
package/src/nodes/sensor.ts
CHANGED
|
@@ -35,11 +35,9 @@ type Sensor<T extends {}> = {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
38
|
+
* Configuration options for `createSensor`.
|
|
39
39
|
*
|
|
40
|
-
* @template T - The type of value
|
|
41
|
-
* @param set - A function to set the observed value
|
|
42
|
-
* @returns A cleanup function when the sensor stops being watched
|
|
40
|
+
* @template T - The type of value produced by the sensor
|
|
43
41
|
*/
|
|
44
42
|
type SensorOptions<T extends {}> = SignalOptions<T> & {
|
|
45
43
|
/**
|
|
@@ -49,6 +47,14 @@ type SensorOptions<T extends {}> = SignalOptions<T> & {
|
|
|
49
47
|
value?: T
|
|
50
48
|
}
|
|
51
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Setup callback for `createSensor`. Invoked when the sensor gains its first downstream
|
|
52
|
+
* subscriber; receives a `set` function to push new values into the graph.
|
|
53
|
+
*
|
|
54
|
+
* @template T - The type of value produced by the sensor
|
|
55
|
+
* @param set - Updates the sensor value and propagates the change to subscribers
|
|
56
|
+
* @returns A cleanup function invoked when the sensor loses all subscribers
|
|
57
|
+
*/
|
|
52
58
|
type SensorCallback<T extends {}> = (set: (next: T) => void) => Cleanup
|
|
53
59
|
|
|
54
60
|
/* === Exported Functions === */
|
package/src/nodes/store.ts
CHANGED
|
@@ -28,7 +28,11 @@ import { createState, type State } from './state'
|
|
|
28
28
|
|
|
29
29
|
/* === Types === */
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Configuration options for `createStore`.
|
|
33
|
+
*/
|
|
31
34
|
type StoreOptions = {
|
|
35
|
+
/** Invoked when the store gains its first downstream subscriber; returns a cleanup called when the last one unsubscribes. */
|
|
32
36
|
watched?: () => Cleanup
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -58,6 +62,13 @@ type BaseStore<T extends UnknownRecord> = {
|
|
|
58
62
|
remove(key: string): void
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A reactive object with per-property reactivity.
|
|
67
|
+
* Each property is wrapped as a `State`, nested `Store`, or `List` signal, accessible directly via proxy.
|
|
68
|
+
* Updating one property only re-runs effects that read that property.
|
|
69
|
+
*
|
|
70
|
+
* @template T - The plain-object type whose properties become reactive signals
|
|
71
|
+
*/
|
|
61
72
|
type Store<T extends UnknownRecord> = BaseStore<T> & {
|
|
62
73
|
[K in keyof T]: T[K] extends readonly (infer U extends {})[]
|
|
63
74
|
? List<U>
|
package/src/signal.ts
CHANGED
|
@@ -22,6 +22,12 @@ import { isAsyncFunction, isFunction, isRecord, isUniformArray } from './util'
|
|
|
22
22
|
|
|
23
23
|
/* === Types === */
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A readable and writable signal — the type union of `State`, `Store`, and `List`.
|
|
27
|
+
* Use as a parameter type for generic code that accepts any writable signal.
|
|
28
|
+
*
|
|
29
|
+
* @template T - The type of value held by the signal
|
|
30
|
+
*/
|
|
25
31
|
type MutableSignal<T extends {}> = {
|
|
26
32
|
get(): T
|
|
27
33
|
set(value: T): void
|
package/test/benchmark.test.ts
CHANGED
|
@@ -298,7 +298,8 @@ for (const framework of [v18]) {
|
|
|
298
298
|
Object.fromEntries(heads.map(h => h.read()).entries()),
|
|
299
299
|
)
|
|
300
300
|
const splited = heads
|
|
301
|
-
|
|
301
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
302
|
+
.map((_, index) => framework.computed(() => mux.read()[index]!))
|
|
302
303
|
.map(x => framework.computed(() => x.read() + 1))
|
|
303
304
|
|
|
304
305
|
for (const x of splited) {
|
|
@@ -310,15 +311,19 @@ for (const framework of [v18]) {
|
|
|
310
311
|
return () => {
|
|
311
312
|
for (let i = 0; i < 10; i++) {
|
|
312
313
|
framework.withBatch(() => {
|
|
313
|
-
|
|
314
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
315
|
+
heads[i]!.write(i)
|
|
314
316
|
})
|
|
315
|
-
|
|
317
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
318
|
+
expect(splited[i]!.read()).toBe(i + 1)
|
|
316
319
|
}
|
|
317
320
|
for (let i = 0; i < 10; i++) {
|
|
318
321
|
framework.withBatch(() => {
|
|
319
|
-
|
|
322
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
323
|
+
heads[i]!.write(i * 2)
|
|
320
324
|
})
|
|
321
|
-
|
|
325
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
326
|
+
expect(splited[i]!.read()).toBe(i * 2 + 1)
|
|
322
327
|
}
|
|
323
328
|
}
|
|
324
329
|
})
|
|
@@ -455,16 +460,19 @@ for (const framework of [v18]) {
|
|
|
455
460
|
})),
|
|
456
461
|
)
|
|
457
462
|
const E = framework.computed(() =>
|
|
458
|
-
|
|
463
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
464
|
+
hard(C.read() + A.read() + D.read()[0]!.x, 'E'),
|
|
459
465
|
)
|
|
460
466
|
const F = framework.computed(() =>
|
|
461
|
-
|
|
467
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
468
|
+
hard(D.read()[2]!.x || B.read(), 'F'),
|
|
462
469
|
)
|
|
463
470
|
const G = framework.computed(
|
|
464
471
|
() =>
|
|
465
472
|
C.read() +
|
|
466
473
|
(C.read() || E.read() % 2) +
|
|
467
|
-
|
|
474
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
475
|
+
D.read()[4]!.x +
|
|
468
476
|
F.read(),
|
|
469
477
|
)
|
|
470
478
|
framework.effect(() => {
|
|
@@ -527,10 +535,16 @@ for (const framework of [v18]) {
|
|
|
527
535
|
prop3: framework.signal(3),
|
|
528
536
|
prop4: framework.signal(4),
|
|
529
537
|
}
|
|
530
|
-
|
|
538
|
+
type CellxLayer = {
|
|
539
|
+
prop1: Computed<number>
|
|
540
|
+
prop2: Computed<number>
|
|
541
|
+
prop3: Computed<number>
|
|
542
|
+
prop4: Computed<number>
|
|
543
|
+
}
|
|
544
|
+
let layer: CellxLayer = start
|
|
531
545
|
|
|
532
546
|
for (let i = layers; i > 0; i--) {
|
|
533
|
-
const m = layer
|
|
547
|
+
const m: CellxLayer = layer
|
|
534
548
|
const s = {
|
|
535
549
|
prop1: framework.computed(() => m.prop2.read()),
|
|
536
550
|
prop2: framework.computed(
|
|
@@ -598,7 +612,7 @@ for (const framework of [v18]) {
|
|
|
598
612
|
end.prop4.read(),
|
|
599
613
|
]
|
|
600
614
|
|
|
601
|
-
return [before, after]
|
|
615
|
+
return [before, after] as [number[], number[]]
|
|
602
616
|
}
|
|
603
617
|
|
|
604
618
|
for (const layers in expected) {
|
package/test/collection.test.ts
CHANGED
|
@@ -498,9 +498,12 @@ describe('Collection', () => {
|
|
|
498
498
|
|
|
499
499
|
const signals = [...doubled]
|
|
500
500
|
expect(signals).toHaveLength(3)
|
|
501
|
-
|
|
502
|
-
expect(signals[
|
|
503
|
-
|
|
501
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
502
|
+
expect(signals[0]!.get()).toBe(2)
|
|
503
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
504
|
+
expect(signals[1]!.get()).toBe(4)
|
|
505
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
506
|
+
expect(signals[2]!.get()).toBe(6)
|
|
504
507
|
})
|
|
505
508
|
|
|
506
509
|
test('should react to source additions', () => {
|
package/test/effect.test.ts
CHANGED
package/test/list.test.ts
CHANGED
|
@@ -323,7 +323,8 @@ describe('List', () => {
|
|
|
323
323
|
const list = createList([10, 20, 30])
|
|
324
324
|
const allKeys = [...list.keys()]
|
|
325
325
|
expect(allKeys).toHaveLength(3)
|
|
326
|
-
|
|
326
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
327
|
+
expect(list.byKey(allKeys[0]!)?.get()).toBe(10)
|
|
327
328
|
})
|
|
328
329
|
})
|
|
329
330
|
|
|
@@ -353,9 +354,12 @@ describe('List', () => {
|
|
|
353
354
|
const list = createList([10, 20, 30])
|
|
354
355
|
const signals = [...list]
|
|
355
356
|
expect(signals).toHaveLength(3)
|
|
356
|
-
|
|
357
|
-
expect(signals[
|
|
358
|
-
|
|
357
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
358
|
+
expect(signals[0]!.get()).toBe(10)
|
|
359
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
360
|
+
expect(signals[1]!.get()).toBe(20)
|
|
361
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
362
|
+
expect(signals[2]!.get()).toBe(30)
|
|
359
363
|
})
|
|
360
364
|
})
|
|
361
365
|
|
package/test/regression.test.ts
CHANGED
|
@@ -66,7 +66,8 @@ describe('Bundle size', () => {
|
|
|
66
66
|
entrypoints: ['./index.ts'],
|
|
67
67
|
minify: true,
|
|
68
68
|
})
|
|
69
|
-
|
|
69
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
70
|
+
const bytes = await result.outputs[0]!.arrayBuffer()
|
|
70
71
|
check('bundleMinified', bytes.byteLength, BUNDLE_MARGIN, 'B')
|
|
71
72
|
})
|
|
72
73
|
|
|
@@ -75,7 +76,8 @@ describe('Bundle size', () => {
|
|
|
75
76
|
entrypoints: ['./index.ts'],
|
|
76
77
|
minify: true,
|
|
77
78
|
})
|
|
78
|
-
|
|
79
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
80
|
+
const bytes = await result.outputs[0]!.arrayBuffer()
|
|
79
81
|
const gzipped = gzipSync(new Uint8Array(bytes)).byteLength
|
|
80
82
|
check('bundleGzipped', gzipped, BUNDLE_MARGIN, 'B')
|
|
81
83
|
})
|
package/test/store.test.ts
CHANGED
|
@@ -322,10 +322,14 @@ describe('Store', () => {
|
|
|
322
322
|
const user = createStore({ name: 'John', age: 25 })
|
|
323
323
|
const entries = [...user]
|
|
324
324
|
expect(entries).toHaveLength(2)
|
|
325
|
-
|
|
326
|
-
expect(entries[0][
|
|
327
|
-
|
|
328
|
-
expect(entries[
|
|
325
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
326
|
+
expect(entries[0]![0]).toBe('name')
|
|
327
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
328
|
+
expect(entries[0]![1].get()).toBe('John')
|
|
329
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
330
|
+
expect(entries[1]![0]).toBe('age')
|
|
331
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
332
|
+
expect(entries[1]![1].get()).toBe(25)
|
|
329
333
|
})
|
|
330
334
|
|
|
331
335
|
test('should maintain property key ordering', () => {
|
|
@@ -53,7 +53,8 @@ export function runGraph(
|
|
|
53
53
|
): number {
|
|
54
54
|
const rand = new Random('seed')
|
|
55
55
|
const { sources, layers } = graph
|
|
56
|
-
|
|
56
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
57
|
+
const leaves = layers[layers.length - 1]!
|
|
57
58
|
const skipCount = Math.round(leaves.length * (1 - readFraction))
|
|
58
59
|
const readLeaves = removeElems(leaves, skipCount, rand)
|
|
59
60
|
const frameworkName = framework.name.toLowerCase()
|
|
@@ -65,7 +66,8 @@ export function runGraph(
|
|
|
65
66
|
for (let i = 0; i < iterations; i++) {
|
|
66
67
|
framework.withBatch(() => {
|
|
67
68
|
const sourceDex = i % sources.length
|
|
68
|
-
|
|
69
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
70
|
+
sources[sourceDex]!.write(i + sourceDex)
|
|
69
71
|
})
|
|
70
72
|
|
|
71
73
|
for (const leaf of readLeaves) {
|
|
@@ -87,7 +89,8 @@ export function runGraph(
|
|
|
87
89
|
} */
|
|
88
90
|
|
|
89
91
|
const sourceDex = i % sources.length
|
|
90
|
-
|
|
92
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
93
|
+
sources[sourceDex]!.write(i + sourceDex)
|
|
91
94
|
|
|
92
95
|
for (const leaf of readLeaves) {
|
|
93
96
|
leaf.read()
|
|
@@ -153,7 +156,8 @@ function makeRow(
|
|
|
153
156
|
return sources.map((_, myDex) => {
|
|
154
157
|
const mySources: Computed<number>[] = []
|
|
155
158
|
for (let sourceDex = 0; sourceDex < nSources; sourceDex++) {
|
|
156
|
-
|
|
159
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
160
|
+
mySources.push(sources[(myDex + sourceDex) % sources.length]!)
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
const staticNode = random.float() < staticFraction
|
|
@@ -170,7 +174,8 @@ function makeRow(
|
|
|
170
174
|
})
|
|
171
175
|
} else {
|
|
172
176
|
// dynamic node, drops one of the sources depending on the value of the first element
|
|
173
|
-
|
|
177
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
178
|
+
const first = mySources[0]!
|
|
174
179
|
const tail = mySources.slice(1)
|
|
175
180
|
const node = framework.computed(() => {
|
|
176
181
|
counter.count++
|
|
@@ -180,7 +185,8 @@ function makeRow(
|
|
|
180
185
|
|
|
181
186
|
for (let i = 0; i < tail.length; i++) {
|
|
182
187
|
if (shouldDrop && i === dropDex) continue
|
|
183
|
-
|
|
188
|
+
// biome-ignore lint/style/noNonNullAssertion: test
|
|
189
|
+
sum += tail[i]!.read()
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
return sum
|
package/tsconfig.json
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"moduleResolution": "bundler",
|
|
13
13
|
"allowImportingTsExtensions": true,
|
|
14
14
|
"verbatimModuleSyntax": true,
|
|
15
|
+
"erasableSyntaxOnly": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"types": ["bun-types"],
|
|
15
19
|
|
|
16
20
|
// Editor-only mode - no emit
|
|
17
21
|
"noEmit": true,
|
|
@@ -19,13 +23,22 @@
|
|
|
19
23
|
// Best practices
|
|
20
24
|
"strict": true,
|
|
21
25
|
"skipLibCheck": true,
|
|
26
|
+
"noUncheckedIndexedAccess": true,
|
|
27
|
+
"exactOptionalPropertyTypes": true,
|
|
28
|
+
"useUnknownInCatchVariables": true,
|
|
29
|
+
"noUncheckedSideEffectImports": true,
|
|
22
30
|
"noFallthroughCasesInSwitch": true,
|
|
31
|
+
"forceConsistentCasingInFileNames": true,
|
|
23
32
|
|
|
24
33
|
// Some stricter flags (disabled by default)
|
|
25
34
|
"noUnusedLocals": false,
|
|
26
35
|
"noUnusedParameters": false,
|
|
27
36
|
"noPropertyAccessFromIndexSignature": false,
|
|
37
|
+
|
|
38
|
+
/* Performance */
|
|
39
|
+
"incremental": true,
|
|
40
|
+
"tsBuildInfoFile": "./.tsbuildinfo",
|
|
28
41
|
},
|
|
29
42
|
"include": ["./**/*.ts"],
|
|
30
|
-
"exclude": ["node_modules", "types"],
|
|
43
|
+
"exclude": ["node_modules", "types", "index.js"],
|
|
31
44
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @name Cause & Effect
|
|
3
|
-
* @version 0.
|
|
3
|
+
* @version 1.0.0
|
|
4
4
|
* @author Esther Brunner
|
|
5
5
|
*/
|
|
6
6
|
export { CircularDependencyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors';
|
package/types/src/graph.d.ts
CHANGED
|
@@ -3,11 +3,11 @@ type SourceFields<T extends {}> = {
|
|
|
3
3
|
value: T;
|
|
4
4
|
sinks: Edge | null;
|
|
5
5
|
sinksTail: Edge | null;
|
|
6
|
-
stop?: Cleanup;
|
|
6
|
+
stop?: Cleanup | undefined;
|
|
7
7
|
};
|
|
8
8
|
type OptionsFields<T extends {}> = {
|
|
9
9
|
equals: (a: T, b: T) => boolean;
|
|
10
|
-
guard?: Guard<T
|
|
10
|
+
guard?: Guard<T> | undefined;
|
|
11
11
|
};
|
|
12
12
|
type SinkFields = {
|
|
13
13
|
fn: unknown;
|
package/OWNERSHIP_BUG.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# Ownership Bug: Component Scope Disposed by Parent Effect
|
|
2
|
-
|
|
3
|
-
## Symptom
|
|
4
|
-
|
|
5
|
-
In `module-todo`, `form-checkbox` elements wired via `checkboxes: pass(...)` lose their
|
|
6
|
-
reactive effects after the initial render — `setProperty('checked')` stops updating
|
|
7
|
-
`input.checked`, and the `on('change')` event listener is silently removed. Reading
|
|
8
|
-
`fc.checked` (a pull) still works correctly, but reactive push is gone.
|
|
9
|
-
|
|
10
|
-
## Root Cause
|
|
11
|
-
|
|
12
|
-
`createScope` registers its `dispose` on `prevOwner` — the `activeOwner` at the time the
|
|
13
|
-
scope is created. This is the right behavior for *hierarchical component trees* where a
|
|
14
|
-
parent component logically owns its children. But custom elements have a different ownership
|
|
15
|
-
model: **the DOM owns them**, via `connectedCallback` / `disconnectedCallback`.
|
|
16
|
-
|
|
17
|
-
The problem arises when a custom element's `connectedCallback` fires *inside* a
|
|
18
|
-
re-runnable reactive effect:
|
|
19
|
-
|
|
20
|
-
1. `module-todo`'s list sync effect runs inside `flush()` with `activeOwner = listSyncEffect`.
|
|
21
|
-
2. `list.append(li)` connects the `<li>`, which connects the `<form-checkbox>` inside it.
|
|
22
|
-
3. `form-checkbox.connectedCallback()` calls `runEffects(ui, setup(ui))`, which calls
|
|
23
|
-
`createScope`. `prevOwner = listSyncEffect`, so `dispose` is **registered on
|
|
24
|
-
`listSyncEffect`**.
|
|
25
|
-
4. Later, the `items = all('li[data-key]')` MutationObserver fires (the DOM mutation from
|
|
26
|
-
step 2 is detected) and re-queues `listSyncEffect`.
|
|
27
|
-
5. `runEffect(listSyncEffect)` calls `runCleanup(listSyncEffect)`, which calls all
|
|
28
|
-
registered cleanups — including `form-checkbox`'s `dispose`.
|
|
29
|
-
6. `dispose()` runs `runCleanup(fc1Scope)`, which removes the `on('change')` event
|
|
30
|
-
listener and trims the `setProperty` effect's reactive subscriptions.
|
|
31
|
-
7. The `<form-checkbox>` elements are still in the DOM, but their effects are permanently
|
|
32
|
-
gone. `connectedCallback` does not re-fire on already-connected elements.
|
|
33
|
-
|
|
34
|
-
The same problem recurs whenever `listSyncEffect` re-runs for any reason (e.g. a new todo
|
|
35
|
-
is added), disposing the scopes of all existing `<form-checkbox>` elements.
|
|
36
|
-
|
|
37
|
-
## Why `unown` Is the Correct Fix
|
|
38
|
-
|
|
39
|
-
`createScope`'s "register on `prevOwner`" semantics model one ownership relationship:
|
|
40
|
-
*parent reactive scope owns child*. Custom elements model a different one: *the DOM owns
|
|
41
|
-
the component*. `disconnectedCallback` is the authoritative cleanup trigger, not the
|
|
42
|
-
reactive graph.
|
|
43
|
-
|
|
44
|
-
`unown` is the explicit handshake that says "this scope is DOM-owned". It prevents
|
|
45
|
-
`createScope` from registering `dispose` on whatever reactive effect happens to be running
|
|
46
|
-
when `connectedCallback` fires, while leaving `this.#cleanup` + `disconnectedCallback` as
|
|
47
|
-
the sole lifecycle authority.
|
|
48
|
-
|
|
49
|
-
A `createScope`-only approach (without `unown`) has two failure modes:
|
|
50
|
-
|
|
51
|
-
| Scenario | Problem |
|
|
52
|
-
|---|---|
|
|
53
|
-
| Connects in static DOM (`activeOwner = null`) | `dispose` is discarded; effects never cleaned up on disconnect — memory leak |
|
|
54
|
-
| Connects inside a re-runnable effect | Same disposal bug as described above |
|
|
55
|
-
|
|
56
|
-
Per-item scopes (manually tracking a `Map<key, Cleanup>`) could also fix the disposal
|
|
57
|
-
problem but require significant restructuring of the list sync effect and still need
|
|
58
|
-
`unown` to prevent re-registration on each effect re-run.
|
|
59
|
-
|
|
60
|
-
## Required Changes
|
|
61
|
-
|
|
62
|
-
### `@zeix/cause-effect`
|
|
63
|
-
|
|
64
|
-
**`src/graph.ts`** — Add `unown` next to `untrack`:
|
|
65
|
-
|
|
66
|
-
```typescript
|
|
67
|
-
/**
|
|
68
|
-
* Runs a callback without any active owner.
|
|
69
|
-
* Any scopes or effects created inside the callback will not be registered as
|
|
70
|
-
* children of the current active owner (e.g. a re-runnable effect). Use this
|
|
71
|
-
* when a component or resource manages its own lifecycle independently of the
|
|
72
|
-
* reactive graph.
|
|
73
|
-
*
|
|
74
|
-
* @since 0.18.5
|
|
75
|
-
*/
|
|
76
|
-
function unown<T>(fn: () => T): T {
|
|
77
|
-
const prev = activeOwner
|
|
78
|
-
activeOwner = null
|
|
79
|
-
try {
|
|
80
|
-
return fn()
|
|
81
|
-
} finally {
|
|
82
|
-
activeOwner = prev
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Export it from the internal graph exports and from **`index.ts`**:
|
|
88
|
-
|
|
89
|
-
```typescript
|
|
90
|
-
export {
|
|
91
|
-
// ...existing exports...
|
|
92
|
-
unown,
|
|
93
|
-
untrack,
|
|
94
|
-
} from './src/graph'
|
|
95
|
-
```
|