@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
|
@@ -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,173 @@
|
|
|
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
|
+
<conditional_reads_delay_watched>
|
|
40
|
+
**Conditional signal reads delay `watched` activation.** The `watched` callback on a State
|
|
41
|
+
or Sensor fires when the first downstream effect subscribes. If a signal is only read inside
|
|
42
|
+
a branch that hasn't executed yet, `watched` does not fire until that branch runs.
|
|
43
|
+
|
|
44
|
+
Read all signals you care about eagerly — before any conditional logic — to ensure `watched`
|
|
45
|
+
fires on the first effect run:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// Bad — `derived` is only read after `task` resolves to `ok`
|
|
49
|
+
// `derived.watched` does not fire until the task has a value
|
|
50
|
+
createEffect(() => {
|
|
51
|
+
match([task], {
|
|
52
|
+
ok: ([result]) => render(derived.get(), result),
|
|
53
|
+
nil: () => showSpinner(),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Good — both signals are read on every run, regardless of task state
|
|
58
|
+
// Both `watched` callbacks fire immediately when the effect is created
|
|
59
|
+
createEffect(() => {
|
|
60
|
+
match([task, derived], {
|
|
61
|
+
ok: ([result, value]) => render(value, result),
|
|
62
|
+
nil: () => showSpinner(),
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This also applies to plain `if` / ternary / `&&` patterns — any signal read gated behind a
|
|
68
|
+
condition may not establish its dependency edge until the condition is true.
|
|
69
|
+
</conditional_reads_delay_watched>
|
|
70
|
+
|
|
71
|
+
<equals_suppresses_subtrees>
|
|
72
|
+
**`equals` suppresses entire downstream subgraphs, not just the node it is set on.** When a
|
|
73
|
+
Memo or State recomputes to a value considered equal to the previous one, all downstream
|
|
74
|
+
nodes skip recomputation entirely without running their callbacks.
|
|
75
|
+
|
|
76
|
+
This is a powerful optimisation, but it has a non-obvious consequence: a custom `equals` on
|
|
77
|
+
an intermediate Memo can silently prevent large parts of the graph from updating, even if
|
|
78
|
+
upstream sources changed.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
const source = createState({ x: 1, y: 2 })
|
|
82
|
+
|
|
83
|
+
// This memo compares by x only
|
|
84
|
+
const xOnly = createMemo(
|
|
85
|
+
() => source.get().x,
|
|
86
|
+
{ equals: (a, b) => a === b }
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// This effect depends on xOnly.
|
|
90
|
+
// It will NOT re-run if source changes but x stays the same,
|
|
91
|
+
// even if y changed dramatically.
|
|
92
|
+
createEffect(() => {
|
|
93
|
+
console.log('x is', xOnly.get())
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
When debugging "why did my effect not re-run", check for custom `equals` on intermediate
|
|
98
|
+
memos in the dependency chain.
|
|
99
|
+
</equals_suppresses_subtrees>
|
|
100
|
+
|
|
101
|
+
<watched_stable_through_mutations>
|
|
102
|
+
**`watched` stays active through structural mutations.** The `watched` callback on a List or
|
|
103
|
+
Collection source is called once when the first downstream effect subscribes, and `unwatched`
|
|
104
|
+
is called when the last downstream effect unsubscribes. Structural mutations (adding items,
|
|
105
|
+
removing items, updating values) do not call `unwatched` then `watched` again — the callback
|
|
106
|
+
remains active for the lifetime of the subscription.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const list = createList(
|
|
110
|
+
() => startPolling(), // watched: called once when first effect subscribes
|
|
111
|
+
() => stopPolling(), // unwatched: called once when last effect unsubscribes
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// These mutations do NOT restart the watched/unwatched cycle.
|
|
115
|
+
// The data source stays open as long as at least one effect is subscribed.
|
|
116
|
+
list.push({ id: '1', name: 'Item 1' }) // watched is NOT called again
|
|
117
|
+
list.delete('1') // watched is NOT called again
|
|
118
|
+
```
|
|
119
|
+
</watched_stable_through_mutations>
|
|
120
|
+
|
|
121
|
+
<task_abort_on_dependency_change>
|
|
122
|
+
**A Task's `AbortSignal` is aborted when dependencies change before the async operation
|
|
123
|
+
completes.** If a Task's sources update while the previous `Promise` is still pending, a new
|
|
124
|
+
run is scheduled and the previous `AbortController` is aborted. Not forwarding the signal to
|
|
125
|
+
cancellable async operations will cause stale results to overwrite fresh ones.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// Wrong — fetch is not cancellable; a stale response may arrive after a newer one
|
|
129
|
+
const results = createTask(async () => {
|
|
130
|
+
return fetch(`/api/search?q=${query.get()}`).then(r => r.json())
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Correct — abort signal forwarded; stale in-flight requests are cancelled
|
|
134
|
+
const results = createTask(async (prev, signal) => {
|
|
135
|
+
return fetch(`/api/search?q=${query.get()}`, { signal }).then(r => r.json())
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
</task_abort_on_dependency_change>
|
|
139
|
+
|
|
140
|
+
<sensor_unset_before_first_value>
|
|
141
|
+
**Reading a Sensor or Task before it has produced a value throws `UnsetSignalValueError`.**
|
|
142
|
+
Unlike State, these signals have no initial value — they are explicitly "unset" until the
|
|
143
|
+
first value arrives.
|
|
144
|
+
|
|
145
|
+
Guard against this with `match`, which provides a `nil` branch for the unset case:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const tick = createSensor<number>(set => {
|
|
149
|
+
const id = setInterval(() => set(Date.now()), 1000)
|
|
150
|
+
return () => clearInterval(id)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Wrong — throws UnsetSignalValueError on first run, before the interval fires
|
|
154
|
+
createEffect(() => {
|
|
155
|
+
console.log(tick.get())
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Correct — match handles the nil (unset) case explicitly
|
|
159
|
+
createEffect(() => {
|
|
160
|
+
match([tick], {
|
|
161
|
+
ok: ([timestamp]) => console.log('tick:', timestamp),
|
|
162
|
+
nil: () => console.log('waiting for first tick…'),
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
```
|
|
166
|
+
</sensor_unset_before_first_value>
|
|
167
|
+
|
|
168
|
+
<scope_cleanup_is_synchronous>
|
|
169
|
+
**Scope and Effect cleanup runs synchronously when the returned `Cleanup` function is
|
|
170
|
+
called.** It does not wait for the current flush to complete. Calling cleanup during a batch
|
|
171
|
+
(e.g. inside a `batch` callback) is safe but will immediately dispose the owner and all its
|
|
172
|
+
children.
|
|
173
|
+
</scope_cleanup_is_synchronous>
|