@zeix/cause-effect 1.0.0 → 1.0.2

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 (62) hide show
  1. package/.github/copilot-instructions.md +5 -1
  2. package/.zed/settings.json +24 -1
  3. package/ARCHITECTURE.md +27 -1
  4. package/CHANGELOG.md +21 -0
  5. package/CLAUDE.md +1 -1
  6. package/GUIDE.md +2 -5
  7. package/README.md +45 -3
  8. package/REQUIREMENTS.md +3 -3
  9. package/eslint.config.js +2 -1
  10. package/index.dev.js +14 -0
  11. package/index.js +1 -1
  12. package/index.ts +1 -1
  13. package/package.json +5 -4
  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 +195 -0
  19. package/skills/cause-effect/references/signal-types.md +292 -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 +61 -100
  24. package/skills/cause-effect-dev/references/api-facts.md +96 -0
  25. package/skills/cause-effect-dev/references/error-classes.md +97 -0
  26. package/skills/cause-effect-dev/references/internal-types.md +54 -0
  27. package/skills/cause-effect-dev/references/non-obvious-behaviors.md +162 -0
  28. package/skills/cause-effect-dev/references/source-map.md +45 -0
  29. package/skills/cause-effect-dev/workflows/answer-question.md +55 -0
  30. package/skills/cause-effect-dev/workflows/fix-bug.md +63 -0
  31. package/skills/cause-effect-dev/workflows/implement-feature.md +46 -0
  32. package/skills/cause-effect-dev/workflows/write-tests.md +64 -0
  33. package/skills/changelog-keeper/SKILL.md +47 -37
  34. package/skills/tech-writer/SKILL.md +94 -0
  35. package/skills/tech-writer/references/document-map.md +199 -0
  36. package/skills/tech-writer/references/tone-guide.md +189 -0
  37. package/skills/tech-writer/workflows/consistency-review.md +98 -0
  38. package/skills/tech-writer/workflows/update-after-change.md +65 -0
  39. package/skills/tech-writer/workflows/update-agent-docs.md +77 -0
  40. package/skills/tech-writer/workflows/update-architecture.md +61 -0
  41. package/skills/tech-writer/workflows/update-jsdoc.md +72 -0
  42. package/skills/tech-writer/workflows/update-public-api.md +59 -0
  43. package/skills/tech-writer/workflows/update-requirements.md +80 -0
  44. package/src/graph.ts +2 -0
  45. package/src/nodes/collection.ts +38 -0
  46. package/src/nodes/effect.ts +13 -1
  47. package/src/nodes/list.ts +41 -2
  48. package/src/nodes/memo.ts +0 -1
  49. package/src/nodes/sensor.ts +10 -4
  50. package/src/nodes/store.ts +11 -0
  51. package/src/signal.ts +6 -0
  52. package/test/list.test.ts +121 -0
  53. package/tsconfig.json +9 -0
  54. package/types/index.d.ts +1 -1
  55. package/types/src/graph.d.ts +2 -0
  56. package/types/src/nodes/collection.d.ts +38 -0
  57. package/types/src/nodes/effect.d.ts +13 -1
  58. package/types/src/nodes/list.d.ts +30 -2
  59. package/types/src/nodes/memo.d.ts +0 -1
  60. package/types/src/nodes/sensor.d.ts +10 -4
  61. package/types/src/nodes/store.d.ts +11 -0
  62. package/types/src/signal.d.ts +6 -0
@@ -0,0 +1,179 @@
1
+ <overview>
2
+ Key API constraints, defaults, and callback patterns for @zeix/cause-effect. All knowledge is
3
+ self-contained — no library source files required. Read this when writing or reviewing any
4
+ code that uses the public API.
5
+ </overview>
6
+
7
+ <type_constraint>
8
+ **`T extends {}`** — all signal generics exclude `null` and `undefined` at the type level.
9
+ This is intentional: signals always have a value; absence must be modelled explicitly.
10
+
11
+ ```typescript
12
+ // Wrong — TypeScript will reject this
13
+ const count = createState<number | null>(null)
14
+
15
+ // Correct — use a sentinel or a wrapper type
16
+ const count = createState<number>(0)
17
+ const selected = createState<{ id: string } | { id: never }>({ id: '' })
18
+ ```
19
+ </type_constraint>
20
+
21
+ <core_functions>
22
+ **`createScope(fn)`**
23
+ - Returns a single `Cleanup` function
24
+ - `fn` receives no arguments and may return an optional cleanup
25
+ - Use to group effects and control their shared lifetime
26
+
27
+ ```typescript
28
+ const dispose = createScope(() => {
29
+ createEffect(() => console.log(count.get()))
30
+ // all effects inside are disposed when dispose() is called
31
+ })
32
+ dispose() // cleans up everything inside
33
+ ```
34
+
35
+ **`createEffect(fn)`**
36
+ - Returns a `Cleanup` function
37
+ - **Must be called inside an owner** (a `createScope` callback or another `createEffect` callback)
38
+ - Throws `RequiredOwnerError` if called without an active owner
39
+ - Runs `fn` immediately, then re-runs whenever tracked dependencies change
40
+
41
+ **`batch(fn)`**
42
+ - Defers the reactive flush until `fn` returns
43
+ - Multiple state writes inside `fn` coalesce into a single propagation pass
44
+ - Use when updating several signals that feed the same downstream computation
45
+
46
+ ```typescript
47
+ batch(() => {
48
+ x.set(1)
49
+ y.set(2)
50
+ z.set(3)
51
+ // only one propagation pass runs after all three writes
52
+ })
53
+ ```
54
+
55
+ **`untrack(fn)`**
56
+ - Runs `fn` without recording dependency edges
57
+ - Reads inside `fn` do not subscribe the current computation to those signals
58
+ - Use to read a signal's current value without creating a reactive dependency
59
+
60
+ ```typescript
61
+ createEffect(() => {
62
+ const a = reactive.get() // tracked — effect re-runs when reactive changes
63
+ const b = untrack(() => other.get()) // untracked — no dependency on other
64
+ render(a, b)
65
+ })
66
+ ```
67
+
68
+ **`unown(fn)`**
69
+ - Runs `fn` without registering cleanups in the current owner
70
+ - Use in `connectedCallback` and similar DOM lifecycle methods where the DOM —
71
+ not the reactive graph — manages the element's lifetime
72
+
73
+ ```typescript
74
+ connectedCallback() {
75
+ // cleanup is tied to disconnectedCallback, not to a reactive owner
76
+ this.#cleanup = unown(() => createEffect(() => this.render()))
77
+ }
78
+ disconnectedCallback() {
79
+ this.#cleanup?.()
80
+ }
81
+ ```
82
+ </core_functions>
83
+
84
+ <options>
85
+ **`equals`**
86
+ - Available on `createState`, `createSensor`, `createMemo`, `createTask`
87
+ - Default: strict equality (`===`)
88
+ - When a new value is considered equal to the previous one, propagation stops —
89
+ downstream nodes are not re-run
90
+ - **`SKIP_EQUALITY`** — special sentinel value for `equals`; forces propagation on every
91
+ update regardless of value. Use with mutable-reference sensors where the object
92
+ reference never changes but the contents do:
93
+
94
+ ```typescript
95
+ import { createSensor, SKIP_EQUALITY } from '@zeix/cause-effect'
96
+
97
+ const mouse = createSensor<{ x: number; y: number }>(
98
+ set => {
99
+ const handler = (e: MouseEvent) => set({ x: e.clientX, y: e.clientY })
100
+ window.addEventListener('mousemove', handler)
101
+ return () => window.removeEventListener('mousemove', handler)
102
+ },
103
+ { equals: SKIP_EQUALITY } // new object every time, so skip reference equality
104
+ )
105
+ ```
106
+
107
+ **`guard`**
108
+ - Available on `createState`, `createSensor`
109
+ - A predicate `(value: unknown) => value is T`
110
+ - Throws `InvalidSignalValueError` if a set value fails the predicate
111
+ - Use to enforce runtime type safety at signal boundaries
112
+
113
+ ```typescript
114
+ const age = createState(0, {
115
+ guard: (v): v is number => typeof v === 'number' && v >= 0,
116
+ })
117
+ ```
118
+ </options>
119
+
120
+ <callback_patterns>
121
+ **Memo and Task callbacks receive `prev`**
122
+ - Signature: `(prev: T) => T` for Memo; `(prev: T, signal: AbortSignal) => Promise<T>` for Task
123
+ - `prev` is the previous computed value, enabling reducer-style patterns without external state:
124
+
125
+ ```typescript
126
+ const runningTotal = createMemo((prev: number) => prev + newValue.get())
127
+ ```
128
+
129
+ **Task carries an `AbortSignal`**
130
+ - The second argument to the Task callback is an `AbortSignal`
131
+ - The signal is aborted when dependencies change before the previous async run completes
132
+ - Always forward it to any `fetch` or cancellable async operation:
133
+
134
+ ```typescript
135
+ const results = createTask(async (prev, signal) => {
136
+ const res = await fetch(`/api/search?q=${query.get()}`, { signal })
137
+ return res.json()
138
+ })
139
+ ```
140
+
141
+ **`Slot` is a property descriptor**
142
+ - Has `get`, `set`, `configurable`, `enumerable` fields
143
+ - Can be passed directly to `Object.defineProperty()`:
144
+
145
+ ```typescript
146
+ const nameSlot = createSlot(store, 'name')
147
+ Object.defineProperty(element, 'name', nameSlot)
148
+ ```
149
+ </callback_patterns>
150
+
151
+ <match_helper>
152
+ `match` reads one or more Sensor/Task signals and routes to `ok` or `nil` based on whether
153
+ all signals have a value. Use it to safely handle the unset state without try/catch:
154
+
155
+ ```typescript
156
+ import { match } from '@zeix/cause-effect'
157
+
158
+ createEffect(() => {
159
+ match([task, sensor], {
160
+ ok: ([taskResult, sensorValue]) => render(taskResult, sensorValue),
161
+ nil: () => showSpinner(),
162
+ })
163
+ })
164
+ ```
165
+
166
+ Read signals you care about eagerly inside `match`'s array — not inside individual branches.
167
+ See `non-obvious-behaviors.md → conditional-reads-delay-watched` for why.
168
+ </match_helper>
169
+
170
+ <lifecycle_summary>
171
+ | Function | Requires owner? | Returns | Reactive? |
172
+ |---|---|---|---|
173
+ | `createScope(fn)` | No | `Cleanup` | No (fn runs once) |
174
+ | `createEffect(fn)` | **Yes** | `Cleanup` | Yes — re-runs on dependency change |
175
+ | `createMemo(fn)` | No | `Memo<T>` | Lazy — recomputes on read if stale |
176
+ | `createTask(fn)` | No | `Task<T>` | Yes — re-runs async on dependency change |
177
+ | `createState(value)` | No | `State<T>` | Source — never recomputes |
178
+ | `createSensor(setup)` | No | `Sensor<T>` | Source — set by external callback |
179
+ </lifecycle_summary>
@@ -0,0 +1,153 @@
1
+ <overview>
2
+ Error classes thrown by @zeix/cause-effect and the conditions that trigger them. All knowledge
3
+ is self-contained — no library source files required. Read this when writing error-handling
4
+ code, testing error conditions, or diagnosing an unexpected throw.
5
+ </overview>
6
+
7
+ <import>
8
+ All error classes are exported from the package root:
9
+
10
+ ```typescript
11
+ import {
12
+ NullishSignalValueError,
13
+ InvalidSignalValueError,
14
+ InvalidCallbackError,
15
+ DuplicateKeyError,
16
+ UnsetSignalValueError,
17
+ ReadonlySignalError,
18
+ RequiredOwnerError,
19
+ CircularDependencyError,
20
+ } from '@zeix/cause-effect'
21
+ ```
22
+ </import>
23
+
24
+ <error_table>
25
+ | Class | When thrown |
26
+ |---|---|
27
+ | `NullishSignalValueError` | Signal value is `null` or `undefined` |
28
+ | `InvalidSignalValueError` | Value fails the `guard` predicate |
29
+ | `InvalidCallbackError` | A required callback argument is not a function |
30
+ | `DuplicateKeyError` | List/Collection key collision on insert |
31
+ | `UnsetSignalValueError` | Reading a Sensor or Task before it has produced its first value |
32
+ | `ReadonlySignalError` | Attempting to write to a read-only signal |
33
+ | `RequiredOwnerError` | `createEffect` called outside an owner (scope or parent effect) |
34
+ | `CircularDependencyError` | A cycle is detected in the reactive graph |
35
+ </error_table>
36
+
37
+ <error_details>
38
+
39
+ <NullishSignalValueError>
40
+ Thrown when a signal's value is `null` or `undefined`. Because all signal generics use
41
+ `T extends {}`, nullish values are excluded by design — this error surfaces the constraint
42
+ at runtime if type safety is bypassed (e.g. via a type assertion or untyped interop).
43
+
44
+ **Prevention:** model absence explicitly with a sentinel value or wrapper type instead of `null`.
45
+ </NullishSignalValueError>
46
+
47
+ <InvalidSignalValueError>
48
+ Thrown when a value passed to `.set()` fails the `guard` predicate supplied in the signal's
49
+ options. This is the runtime enforcement of custom type narrowing at signal boundaries.
50
+
51
+ ```typescript
52
+ import { createState, InvalidSignalValueError } from '@zeix/cause-effect'
53
+
54
+ const age = createState(0, {
55
+ guard: (v): v is number => typeof v === 'number' && v >= 0,
56
+ })
57
+
58
+ age.set(-1) // throws InvalidSignalValueError
59
+ ```
60
+ </InvalidSignalValueError>
61
+
62
+ <InvalidCallbackError>
63
+ Thrown when a required callback argument — such as the computation function passed to
64
+ `createMemo`, `createTask`, or `createEffect` — is not a function. Catches programming
65
+ errors like passing `undefined` or a non-function value by mistake.
66
+ </InvalidCallbackError>
67
+
68
+ <DuplicateKeyError>
69
+ Thrown when inserting an item into a List or Collection whose key already exists. Keys must
70
+ be unique within a given List or Collection.
71
+
72
+ **Fix:** use the collection's update or set method to change an existing entry rather than
73
+ inserting a new one with the same key.
74
+ </DuplicateKeyError>
75
+
76
+ <UnsetSignalValueError>
77
+ Thrown when `.get()` is called on a Sensor or Task before it has emitted its first value.
78
+ Unlike State, Sensor and Task start in an explicitly unset state with no initial value.
79
+
80
+ **Fix:** use `match` to handle the unset state (`nil` branch) instead of calling `.get()`
81
+ directly:
82
+
83
+ ```typescript
84
+ import { match } from '@zeix/cause-effect'
85
+
86
+ createEffect(() => {
87
+ match([sensor, task], {
88
+ ok: ([s, t]) => render(s, t),
89
+ nil: () => showSpinner(),
90
+ })
91
+ })
92
+ ```
93
+ </UnsetSignalValueError>
94
+
95
+ <ReadonlySignalError>
96
+ Thrown when code attempts to call `.set()` on a read-only signal. Derived signals (Memo,
97
+ Task) are inherently read-only. Certain factory options may also produce read-only State
98
+ or Sensor instances.
99
+
100
+ **Fix:** only write to signals you own (State, Sensor via the internal setter callback).
101
+ </ReadonlySignalError>
102
+
103
+ <RequiredOwnerError>
104
+ Thrown when `createEffect` is called without an active owner in the current execution context.
105
+ Effects must be created inside a `createScope` callback or inside another `createEffect`
106
+ callback so their cleanup can be registered and managed.
107
+
108
+ ```typescript
109
+ import { createEffect, createScope } from '@zeix/cause-effect'
110
+
111
+ // Wrong — no active owner
112
+ createEffect(() => console.log('runs')) // throws RequiredOwnerError
113
+
114
+ // Correct — wrapped in a scope
115
+ const dispose = createScope(() => {
116
+ createEffect(() => console.log('runs'))
117
+ })
118
+ ```
119
+
120
+ **Exception:** use `unown` when the DOM manages the element's lifetime (e.g. inside
121
+ `connectedCallback`/`disconnectedCallback`) and you intentionally want to bypass owner
122
+ registration.
123
+ </RequiredOwnerError>
124
+
125
+ <CircularDependencyError>
126
+ Thrown when the graph engine detects a cycle during propagation — a signal that, directly
127
+ or transitively, depends on itself. Cycles make it impossible to determine a stable
128
+ evaluation order and are always a programming error.
129
+
130
+ **Common causes:**
131
+ - A Memo or Task that writes to a State it also reads
132
+ - Two Memos that read each other
133
+
134
+ **Fix:** restructure the data flow so that values move in one direction only.
135
+ </CircularDependencyError>
136
+
137
+ </error_details>
138
+
139
+ <testing_error_conditions>
140
+ Use `expect(() => ...).toThrow(ErrorClass)` to assert that a specific error is thrown.
141
+ Import the error class from the package root:
142
+
143
+ ```typescript
144
+ import { createState, InvalidSignalValueError } from '@zeix/cause-effect'
145
+
146
+ test('rejects negative age', () => {
147
+ const age = createState(0, {
148
+ guard: (v): v is number => typeof v === 'number' && v >= 0,
149
+ })
150
+ expect(() => age.set(-1)).toThrow(InvalidSignalValueError)
151
+ })
152
+ ```
153
+ </testing_error_conditions>
@@ -0,0 +1,195 @@
1
+ <overview>
2
+ Counterintuitive behaviors in @zeix/cause-effect that commonly cause bugs or confusion.
3
+ All knowledge is self-contained — no library source files required. Read this when debugging
4
+ unexpected reactive behavior, or when writing code that involves collections, conditional
5
+ reads, async operations, or ownership.
6
+ </overview>
7
+
8
+ <direct_lookups_do_not_track>
9
+ **`byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do not create graph edges.** They are
10
+ direct lookups into the internal map/array — calling them inside an effect or memo does not
11
+ subscribe to structural changes.
12
+
13
+ To react to structural changes (key added, key removed, order changed), read a tracking
14
+ accessor instead:
15
+
16
+ | You want to react to | Read this |
17
+ |---|---|
18
+ | Any structural change | `collection.get()` or `list.get()` |
19
+ | Key set membership | `collection.keys()` |
20
+ | Length / item count | `collection.length` |
21
+ | A specific item's value | `collection.get()` then access the item |
22
+
23
+ ```typescript
24
+ // Wrong — effect does not re-run when keys are added or removed
25
+ createEffect(() => {
26
+ const item = collection.byKey('id-123')
27
+ render(item)
28
+ })
29
+
30
+ // Correct — reading keys() creates a dependency on structural changes
31
+ createEffect(() => {
32
+ const keys = collection.keys() // tracks structure
33
+ const item = collection.byKey('id-123') // safe after establishing the edge
34
+ render(item)
35
+ })
36
+ ```
37
+ </direct_lookups_do_not_track>
38
+
39
+ <bykey_set_does_not_propagate_to_structural_subscribers>
40
+ **`byKey(key).set(value)` does not propagate to effects that subscribed via `list.keys()`,
41
+ `list.length`, or the iterator.** Those effects subscribe to the list's structural node but
42
+ do not establish item-level edges, so a direct item signal mutation reaches them only if
43
+ `list.get()` has previously been called to link the item signal to the list node.
44
+
45
+ Use `list.replace(key, value)` for imperative item updates. It propagates through both paths
46
+ — item-level edges and the structural node — regardless of how subscribers are attached.
47
+
48
+ ```typescript
49
+ // Wrong — silently does nothing for effects that subscribed via list.keys()
50
+ list.byKey(key)?.set(newValue)
51
+
52
+ // Correct — guaranteed propagation to all subscribers
53
+ list.replace(key, newValue)
54
+ ```
55
+
56
+ `byKey(key).set(value)` is safe only when the consuming effect directly calls
57
+ `byKey(key).get()` inside its body — that creates a direct edge from the item signal to the
58
+ effect, bypassing the list node entirely.
59
+ </bykey_set_does_not_propagate_to_structural_subscribers>
60
+
61
+ <conditional_reads_delay_watched>
62
+ **Conditional signal reads delay `watched` activation.** The `watched` callback on a State
63
+ or Sensor fires when the first downstream effect subscribes. If a signal is only read inside
64
+ a branch that hasn't executed yet, `watched` does not fire until that branch runs.
65
+
66
+ Read all signals you care about eagerly — before any conditional logic — to ensure `watched`
67
+ fires on the first effect run:
68
+
69
+ ```typescript
70
+ // Bad — `derived` is only read after `task` resolves to `ok`
71
+ // `derived.watched` does not fire until the task has a value
72
+ createEffect(() => {
73
+ match([task], {
74
+ ok: ([result]) => render(derived.get(), result),
75
+ nil: () => showSpinner(),
76
+ })
77
+ })
78
+
79
+ // Good — both signals are read on every run, regardless of task state
80
+ // Both `watched` callbacks fire immediately when the effect is created
81
+ createEffect(() => {
82
+ match([task, derived], {
83
+ ok: ([result, value]) => render(value, result),
84
+ nil: () => showSpinner(),
85
+ })
86
+ })
87
+ ```
88
+
89
+ This also applies to plain `if` / ternary / `&&` patterns — any signal read gated behind a
90
+ condition may not establish its dependency edge until the condition is true.
91
+ </conditional_reads_delay_watched>
92
+
93
+ <equals_suppresses_subtrees>
94
+ **`equals` suppresses entire downstream subgraphs, not just the node it is set on.** When a
95
+ Memo or State recomputes to a value considered equal to the previous one, all downstream
96
+ nodes skip recomputation entirely without running their callbacks.
97
+
98
+ This is a powerful optimisation, but it has a non-obvious consequence: a custom `equals` on
99
+ an intermediate Memo can silently prevent large parts of the graph from updating, even if
100
+ upstream sources changed.
101
+
102
+ ```typescript
103
+ const source = createState({ x: 1, y: 2 })
104
+
105
+ // This memo compares by x only
106
+ const xOnly = createMemo(
107
+ () => source.get().x,
108
+ { equals: (a, b) => a === b }
109
+ )
110
+
111
+ // This effect depends on xOnly.
112
+ // It will NOT re-run if source changes but x stays the same,
113
+ // even if y changed dramatically.
114
+ createEffect(() => {
115
+ console.log('x is', xOnly.get())
116
+ })
117
+ ```
118
+
119
+ When debugging "why did my effect not re-run", check for custom `equals` on intermediate
120
+ memos in the dependency chain.
121
+ </equals_suppresses_subtrees>
122
+
123
+ <watched_stable_through_mutations>
124
+ **`watched` stays active through structural mutations.** The `watched` callback on a List or
125
+ Collection source is called once when the first downstream effect subscribes, and `unwatched`
126
+ is called when the last downstream effect unsubscribes. Structural mutations (adding items,
127
+ removing items, updating values) do not call `unwatched` then `watched` again — the callback
128
+ remains active for the lifetime of the subscription.
129
+
130
+ ```typescript
131
+ const list = createList(
132
+ () => startPolling(), // watched: called once when first effect subscribes
133
+ () => stopPolling(), // unwatched: called once when last effect unsubscribes
134
+ )
135
+
136
+ // These mutations do NOT restart the watched/unwatched cycle.
137
+ // The data source stays open as long as at least one effect is subscribed.
138
+ list.push({ id: '1', name: 'Item 1' }) // watched is NOT called again
139
+ list.delete('1') // watched is NOT called again
140
+ ```
141
+ </watched_stable_through_mutations>
142
+
143
+ <task_abort_on_dependency_change>
144
+ **A Task's `AbortSignal` is aborted when dependencies change before the async operation
145
+ completes.** If a Task's sources update while the previous `Promise` is still pending, a new
146
+ run is scheduled and the previous `AbortController` is aborted. Not forwarding the signal to
147
+ cancellable async operations will cause stale results to overwrite fresh ones.
148
+
149
+ ```typescript
150
+ // Wrong — fetch is not cancellable; a stale response may arrive after a newer one
151
+ const results = createTask(async () => {
152
+ return fetch(`/api/search?q=${query.get()}`).then(r => r.json())
153
+ })
154
+
155
+ // Correct — abort signal forwarded; stale in-flight requests are cancelled
156
+ const results = createTask(async (prev, signal) => {
157
+ return fetch(`/api/search?q=${query.get()}`, { signal }).then(r => r.json())
158
+ })
159
+ ```
160
+ </task_abort_on_dependency_change>
161
+
162
+ <sensor_unset_before_first_value>
163
+ **Reading a Sensor or Task before it has produced a value throws `UnsetSignalValueError`.**
164
+ Unlike State, these signals have no initial value — they are explicitly "unset" until the
165
+ first value arrives.
166
+
167
+ Guard against this with `match`, which provides a `nil` branch for the unset case:
168
+
169
+ ```typescript
170
+ const tick = createSensor<number>(set => {
171
+ const id = setInterval(() => set(Date.now()), 1000)
172
+ return () => clearInterval(id)
173
+ })
174
+
175
+ // Wrong — throws UnsetSignalValueError on first run, before the interval fires
176
+ createEffect(() => {
177
+ console.log(tick.get())
178
+ })
179
+
180
+ // Correct — match handles the nil (unset) case explicitly
181
+ createEffect(() => {
182
+ match([tick], {
183
+ ok: ([timestamp]) => console.log('tick:', timestamp),
184
+ nil: () => console.log('waiting for first tick…'),
185
+ })
186
+ })
187
+ ```
188
+ </sensor_unset_before_first_value>
189
+
190
+ <scope_cleanup_is_synchronous>
191
+ **Scope and Effect cleanup runs synchronously when the returned `Cleanup` function is
192
+ called.** It does not wait for the current flush to complete. Calling cleanup during a batch
193
+ (e.g. inside a `batch` callback) is safe but will immediately dispose the owner and all its
194
+ children.
195
+ </scope_cleanup_is_synchronous>