@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.
Files changed (63) hide show
  1. package/.github/copilot-instructions.md +2 -1
  2. package/.zed/settings.json +24 -1
  3. package/ARCHITECTURE.md +2 -2
  4. package/CHANGELOG.md +23 -0
  5. package/README.md +42 -1
  6. package/REQUIREMENTS.md +3 -3
  7. package/bench/reactivity.bench.ts +18 -7
  8. package/biome.json +1 -1
  9. package/eslint.config.js +2 -1
  10. package/index.dev.js +11 -5
  11. package/index.js +1 -1
  12. package/index.ts +1 -1
  13. package/package.json +6 -6
  14. package/skills/cause-effect/SKILL.md +69 -0
  15. package/skills/cause-effect/agents/openai.yaml +4 -0
  16. package/skills/cause-effect/references/api-facts.md +179 -0
  17. package/skills/cause-effect/references/error-classes.md +153 -0
  18. package/skills/cause-effect/references/non-obvious-behaviors.md +173 -0
  19. package/skills/cause-effect/references/signal-types.md +288 -0
  20. package/skills/cause-effect/workflows/answer-question.md +54 -0
  21. package/skills/cause-effect/workflows/debug.md +71 -0
  22. package/skills/cause-effect/workflows/use-api.md +63 -0
  23. package/skills/cause-effect-dev/SKILL.md +75 -0
  24. package/skills/cause-effect-dev/agents/openai.yaml +4 -0
  25. package/skills/cause-effect-dev/references/api-facts.md +96 -0
  26. package/skills/cause-effect-dev/references/error-classes.md +97 -0
  27. package/skills/cause-effect-dev/references/internal-types.md +54 -0
  28. package/skills/cause-effect-dev/references/non-obvious-behaviors.md +146 -0
  29. package/skills/cause-effect-dev/references/source-map.md +45 -0
  30. package/skills/cause-effect-dev/workflows/answer-question.md +55 -0
  31. package/skills/cause-effect-dev/workflows/fix-bug.md +63 -0
  32. package/skills/cause-effect-dev/workflows/implement-feature.md +46 -0
  33. package/skills/cause-effect-dev/workflows/write-tests.md +64 -0
  34. package/skills/changelog-keeper/SKILL.md +47 -37
  35. package/skills/tech-writer/SKILL.md +94 -0
  36. package/skills/tech-writer/references/document-map.md +199 -0
  37. package/skills/tech-writer/references/tone-guide.md +189 -0
  38. package/skills/tech-writer/workflows/consistency-review.md +98 -0
  39. package/skills/tech-writer/workflows/update-after-change.md +65 -0
  40. package/skills/tech-writer/workflows/update-agent-docs.md +77 -0
  41. package/skills/tech-writer/workflows/update-architecture.md +61 -0
  42. package/skills/tech-writer/workflows/update-jsdoc.md +72 -0
  43. package/skills/tech-writer/workflows/update-public-api.md +59 -0
  44. package/skills/tech-writer/workflows/update-requirements.md +80 -0
  45. package/src/graph.ts +8 -4
  46. package/src/nodes/collection.ts +42 -2
  47. package/src/nodes/effect.ts +13 -1
  48. package/src/nodes/list.ts +28 -4
  49. package/src/nodes/memo.ts +0 -1
  50. package/src/nodes/sensor.ts +10 -4
  51. package/src/nodes/store.ts +11 -0
  52. package/src/signal.ts +6 -0
  53. package/test/benchmark.test.ts +25 -11
  54. package/test/collection.test.ts +6 -3
  55. package/test/effect.test.ts +2 -1
  56. package/test/list.test.ts +8 -4
  57. package/test/regression.test.ts +4 -2
  58. package/test/store.test.ts +8 -4
  59. package/test/util/dependency-graph.ts +12 -6
  60. package/tsconfig.json +14 -1
  61. package/types/index.d.ts +1 -1
  62. package/types/src/graph.d.ts +2 -2
  63. package/OWNERSHIP_BUG.md +0 -95
@@ -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
- * Runs handlers based on the current values of signals.
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
- if (key && prev[i]) prevByKey.set(key, prev[i])
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 - Optional configuration for key generation and watch lifecycle
243
- * @returns A List signal
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
- return signals.get(keys[index])
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
  }
@@ -35,11 +35,9 @@ type Sensor<T extends {}> = {
35
35
  }
36
36
 
37
37
  /**
38
- * A callback function for sensors when the sensor starts being watched.
38
+ * Configuration options for `createSensor`.
39
39
  *
40
- * @template T - The type of value observed
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 === */
@@ -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
@@ -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
- .map((_, index) => framework.computed(() => mux.read()[index]))
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
- heads[i].write(i)
314
+ // biome-ignore lint/style/noNonNullAssertion: test
315
+ heads[i]!.write(i)
314
316
  })
315
- expect(splited[i].read()).toBe(i + 1)
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
- heads[i].write(i * 2)
322
+ // biome-ignore lint/style/noNonNullAssertion: test
323
+ heads[i]!.write(i * 2)
320
324
  })
321
- expect(splited[i].read()).toBe(i * 2 + 1)
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
- hard(C.read() + A.read() + D.read()[0].x, 'E'),
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
- hard(D.read()[2].x || B.read(), 'F'),
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
- D.read()[4].x +
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
- let layer: Record<string, Computed<number>> = start
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) {
@@ -498,9 +498,12 @@ describe('Collection', () => {
498
498
 
499
499
  const signals = [...doubled]
500
500
  expect(signals).toHaveLength(3)
501
- expect(signals[0].get()).toBe(2)
502
- expect(signals[1].get()).toBe(4)
503
- expect(signals[2].get()).toBe(6)
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', () => {
@@ -414,7 +414,8 @@ describe('match', () => {
414
414
  },
415
415
  err: errors => {
416
416
  errCount++
417
- expect(errors[0].message).toBe('Too high')
417
+ // biome-ignore lint/style/noNonNullAssertion: test
418
+ expect(errors[0]!.message).toBe('Too high')
418
419
  },
419
420
  }),
420
421
  )
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
- expect(list.byKey(allKeys[0])?.get()).toBe(10)
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
- expect(signals[0].get()).toBe(10)
357
- expect(signals[1].get()).toBe(20)
358
- expect(signals[2].get()).toBe(30)
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
 
@@ -66,7 +66,8 @@ describe('Bundle size', () => {
66
66
  entrypoints: ['./index.ts'],
67
67
  minify: true,
68
68
  })
69
- const bytes = await result.outputs[0].arrayBuffer()
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
- const bytes = await result.outputs[0].arrayBuffer()
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
  })
@@ -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
- expect(entries[0][0]).toBe('name')
326
- expect(entries[0][1].get()).toBe('John')
327
- expect(entries[1][0]).toBe('age')
328
- expect(entries[1][1].get()).toBe(25)
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
- const leaves = layers[layers.length - 1]
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
- sources[sourceDex].write(i + sourceDex)
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
- sources[sourceDex].write(i + sourceDex)
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
- mySources.push(sources[(myDex + sourceDex) % sources.length])
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
- const first = mySources[0]
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
- sum += tail[i].read()
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.18.5
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';
@@ -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
- ```