@zeix/cause-effect 1.0.0 → 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 +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +41 -0
- package/REQUIREMENTS.md +3 -3
- package/eslint.config.js +2 -1
- package/package.json +5 -4
- 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 +61 -100
- 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 +2 -0
- package/src/nodes/collection.ts +38 -0
- package/src/nodes/effect.ts +13 -1
- package/src/nodes/list.ts +23 -2
- 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/tsconfig.json +9 -0
|
@@ -7,108 +7,69 @@ description: >
|
|
|
7
7
|
user_invocable: false
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
<scope>
|
|
11
|
+
This skill is for development work **on the @zeix/cause-effect library itself** — use it only inside the cause-effect repository where `REQUIREMENTS.md`, `ARCHITECTURE.md`, and `src/` are present at the project root.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
For consumer projects that use `@zeix/cause-effect` as a dependency, use the `cause-effect` skill instead.
|
|
14
|
+
</scope>
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
<essential_principles>
|
|
17
|
+
**Read before writing.** Always read the relevant source file(s) before proposing or making changes.
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
**The signal type set is complete.** Check `REQUIREMENTS.md` before proposing anything new — new signal types are explicitly out of scope.
|
|
20
|
+
|
|
21
|
+
**`T extends {}`** — all signal generics exclude `null` and `undefined`. Use wrapper types or sentinel values to represent absence.
|
|
22
|
+
|
|
23
|
+
**Run `bun test`** after every change.
|
|
24
|
+
</essential_principles>
|
|
25
|
+
|
|
26
|
+
<intake>
|
|
27
|
+
What kind of task is this?
|
|
28
|
+
|
|
29
|
+
1. **Implement** — add or extend functionality
|
|
30
|
+
2. **Fix** — debug or fix unexpected behavior
|
|
31
|
+
3. **Test** — write or update tests
|
|
32
|
+
4. **Question** — understand the API, internals, or a design decision
|
|
33
|
+
|
|
34
|
+
**Wait for response before proceeding.**
|
|
35
|
+
</intake>
|
|
36
|
+
|
|
37
|
+
<routing>
|
|
38
|
+
| Response | Workflow |
|
|
17
39
|
|---|---|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
|
36
|
-
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
```
|
|
50
|
-
StateNode<T> — source only
|
|
51
|
-
MemoNode<T> — source + sink (also used by Slot, Store, List, Collection internals)
|
|
52
|
-
TaskNode<T> — source + sink + AbortController
|
|
53
|
-
EffectNode — sink + owner
|
|
54
|
-
Scope — owner only
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Two independent global pointers:
|
|
58
|
-
- `activeSink` — tracked for dependency edges (nulled by `untrack()`)
|
|
59
|
-
- `activeOwner` — tracked for cleanup registration (nulled by `unown()`)
|
|
60
|
-
|
|
61
|
-
## Key API Facts
|
|
62
|
-
|
|
63
|
-
- **`T extends {}`** — all signal generics exclude `null` and `undefined`. Use wrapper types or sentinel values to represent absence.
|
|
64
|
-
- **`createScope(fn)`** — returns a single `Cleanup` function. `fn` receives no arguments and returns an optional cleanup.
|
|
65
|
-
- **`createEffect(fn)`** — returns a `Cleanup`. Must be called inside an owner (effect or scope).
|
|
66
|
-
- **`batch(fn)`** — defers flush until `fn` returns; multiple state writes coalesce into one propagation.
|
|
67
|
-
- **`untrack(fn)`** — runs `fn` without recording dependency edges (nulls `activeSink`).
|
|
68
|
-
- **`unown(fn)`** — runs `fn` without registering cleanup in the current owner (nulls `activeOwner`). Use in `connectedCallback` for DOM-owned lifecycles.
|
|
69
|
-
- **`SKIP_EQUALITY`** — sentinel for `options.equals`; forces propagation on every update (use with mutable-reference sensors).
|
|
70
|
-
- **Memo/Task callbacks receive `prev`** — the previous value as first argument, enabling reducer patterns without external state.
|
|
71
|
-
- **`Slot` is a property descriptor** — has `get`, `set`, `configurable`, `enumerable`; can be passed directly to `Object.defineProperty()`.
|
|
72
|
-
|
|
73
|
-
## Non-Obvious Behaviors
|
|
74
|
-
|
|
75
|
-
**`byKey()`, `at()`, `keyAt()`, `indexOfKey()` do not create graph edges.** They are direct lookups. To react to structural changes (key added/removed), read `get()`, `keys()`, or `length`.
|
|
76
|
-
|
|
77
|
-
**Conditional reads delay `watched` activation.** Read signals eagerly before conditional logic to ensure `watched` fires immediately:
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
// Good — both signals tracked on every run
|
|
81
|
-
createEffect(() => {
|
|
82
|
-
match([task, derived], { ok: ([r, v]) => render(v, r), nil: () => showSpinner() })
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
// Bad — derived only tracked after task resolves
|
|
86
|
-
createEffect(() => {
|
|
87
|
-
match([task], { ok: ([r]) => render(derived.get(), r), nil: () => showSpinner() })
|
|
88
|
-
})
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
**`equals` suppresses entire subtrees.** When a Memo recomputes to the same value, downstream nodes receive `FLAG_CHECK` and are skipped without running. A custom `equals` on an intermediate Memo can suppress whole subgraphs.
|
|
92
|
-
|
|
93
|
-
**`watched` stays stable through mutations.** Structural mutations on a List/Collection source do not restart the `watched` callback; it stays active as long as any downstream effect is subscribed.
|
|
94
|
-
|
|
95
|
-
## Error Classes
|
|
96
|
-
|
|
97
|
-
| Class | When thrown |
|
|
40
|
+
| 1, "implement", "add", "extend", "build" | workflows/implement-feature.md |
|
|
41
|
+
| 2, "fix", "bug", "debug", "broken", "wrong" | workflows/fix-bug.md |
|
|
42
|
+
| 3, "test", "spec", "coverage" | workflows/write-tests.md |
|
|
43
|
+
| 4, "question", "explain", "how", "why", "what" | workflows/answer-question.md |
|
|
44
|
+
|
|
45
|
+
**Intent-based routing** (if user provides clear context without selecting):
|
|
46
|
+
- Describes a change to make → workflows/implement-feature.md
|
|
47
|
+
- Describes something not working → workflows/fix-bug.md
|
|
48
|
+
- Asks to write/update tests → workflows/write-tests.md
|
|
49
|
+
- Asks how something works → workflows/answer-question.md
|
|
50
|
+
|
|
51
|
+
**After identifying the workflow, read it and follow it exactly.**
|
|
52
|
+
</routing>
|
|
53
|
+
|
|
54
|
+
<reference_index>
|
|
55
|
+
All in `references/`:
|
|
56
|
+
|
|
57
|
+
| File | Contents |
|
|
58
|
+
|---|---|
|
|
59
|
+
| source-map.md | Authoritative documents + signal source file locations |
|
|
60
|
+
| internal-types.md | Node shapes and global pointers |
|
|
61
|
+
| api-facts.md | Key API constraints and callback patterns |
|
|
62
|
+
| non-obvious-behaviors.md | Counterintuitive behaviors with examples |
|
|
63
|
+
| error-classes.md | Error classes and when they are thrown |
|
|
64
|
+
</reference_index>
|
|
65
|
+
|
|
66
|
+
<workflows_index>
|
|
67
|
+
All in `workflows/`:
|
|
68
|
+
|
|
69
|
+
| Workflow | Purpose |
|
|
98
70
|
|---|---|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
104
|
-
| `ReadonlySignalError` | Writing to a read-only signal |
|
|
105
|
-
| `RequiredOwnerError` | `createEffect` called outside an owner |
|
|
106
|
-
| `CircularDependencyError` | Cycle detected in the graph |
|
|
107
|
-
|
|
108
|
-
## Workflow
|
|
109
|
-
|
|
110
|
-
1. **Read before writing.** Always read the relevant source file(s) before proposing or making changes.
|
|
111
|
-
2. **Check `ARCHITECTURE.md`** for graph-level questions (propagation, ownership, flag semantics).
|
|
112
|
-
3. **Check `README.md`** for public API usage patterns and option signatures.
|
|
113
|
-
4. **Check `REQUIREMENTS.md`** before adding anything new — the signal type set is complete and new types are explicitly out of scope.
|
|
114
|
-
5. **Run `bun test`** after changes to verify correctness.
|
|
71
|
+
| implement-feature.md | Add or extend library functionality |
|
|
72
|
+
| fix-bug.md | Diagnose and fix unexpected behavior |
|
|
73
|
+
| write-tests.md | Write or update tests for a signal type or behavior |
|
|
74
|
+
| answer-question.md | Answer questions about the API, internals, or design |
|
|
75
|
+
</workflows_index>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
Key API constraints, defaults, and callback patterns for @zeix/cause-effect. Read this when writing or reviewing any code that uses the public API.
|
|
3
|
+
</overview>
|
|
4
|
+
|
|
5
|
+
<type_constraint>
|
|
6
|
+
**`T extends {}`** — all signal generics exclude `null` and `undefined` at the type level. This is intentional. Use wrapper types or sentinel values to represent absence:
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
// Wrong — TypeScript will reject this
|
|
10
|
+
const count = createState<number | null>(null)
|
|
11
|
+
|
|
12
|
+
// Correct — use a sentinel or wrapper
|
|
13
|
+
const count = createState<number>(0)
|
|
14
|
+
const value = createState<{ data: string } | never>({ data: '' })
|
|
15
|
+
```
|
|
16
|
+
</type_constraint>
|
|
17
|
+
|
|
18
|
+
<core_functions>
|
|
19
|
+
**`createScope(fn)`**
|
|
20
|
+
- Returns a single `Cleanup` function
|
|
21
|
+
- `fn` receives no arguments
|
|
22
|
+
- `fn` may return an optional cleanup that runs when the scope is disposed
|
|
23
|
+
- Used to group effects and control their lifetime
|
|
24
|
+
|
|
25
|
+
**`createEffect(fn)`**
|
|
26
|
+
- Returns a `Cleanup` function
|
|
27
|
+
- **Must be called inside an owner** (another effect or a scope) — throws `RequiredOwnerError` otherwise
|
|
28
|
+
- `fn` runs immediately and re-runs whenever its tracked dependencies change
|
|
29
|
+
- Registers cleanup with the current `activeOwner`
|
|
30
|
+
|
|
31
|
+
**`batch(fn)`**
|
|
32
|
+
- Defers the reactive flush until `fn` returns
|
|
33
|
+
- Multiple state writes inside `fn` coalesce into a single propagation pass
|
|
34
|
+
- Use when updating several signals that feed the same downstream computation
|
|
35
|
+
|
|
36
|
+
**`untrack(fn)`**
|
|
37
|
+
- Runs `fn` without recording dependency edges (nulls `activeSink`)
|
|
38
|
+
- Reads inside `fn` do not subscribe the current computation to those signals
|
|
39
|
+
- Use to read a signal's current value without creating a reactive dependency
|
|
40
|
+
|
|
41
|
+
**`unown(fn)`**
|
|
42
|
+
- Runs `fn` without registering cleanups in the current owner (nulls `activeOwner`)
|
|
43
|
+
- Use in `connectedCallback` and similar DOM lifecycle hooks where the DOM — not the reactive graph — owns the element's lifetime
|
|
44
|
+
</core_functions>
|
|
45
|
+
|
|
46
|
+
<options>
|
|
47
|
+
**`equals`**
|
|
48
|
+
- Available on `createState`, `createSensor`, `createMemo`, `createTask`
|
|
49
|
+
- Default: strict equality (`===`)
|
|
50
|
+
- When a new value is `equals` to the previous, propagation stops — downstream nodes are not re-run
|
|
51
|
+
- **`SKIP_EQUALITY`** — special sentinel for `equals`; forces propagation on every update regardless of value. Use with mutable-reference sensors where the reference never changes but the contents do
|
|
52
|
+
|
|
53
|
+
**`guard`**
|
|
54
|
+
- Available on `createState`, `createSensor`
|
|
55
|
+
- A predicate `(value: unknown) => value is T`
|
|
56
|
+
- Throws `InvalidSignalValueError` if a set value fails the guard
|
|
57
|
+
- Use to enforce runtime type safety at signal boundaries
|
|
58
|
+
</options>
|
|
59
|
+
|
|
60
|
+
<callback_patterns>
|
|
61
|
+
**Memo and Task callbacks receive `prev`**
|
|
62
|
+
- Signature: `(prev: T) => T` for Memo; `(prev: T, signal: AbortSignal) => Promise<T>` for Task
|
|
63
|
+
- `prev` is the previous value on every run after the first
|
|
64
|
+
- Enables reducer patterns without external state:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
const count = createState(0)
|
|
68
|
+
const doubled = createMemo((prev) => {
|
|
69
|
+
const next = count.get() * 2
|
|
70
|
+
return next === prev ? prev : next // referential stability
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**`Slot` is a property descriptor**
|
|
75
|
+
- Has `get`, `set`, `configurable`, `enumerable` fields
|
|
76
|
+
- Can be passed directly to `Object.defineProperty()`:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
const slot = createSlot(store, 'name')
|
|
80
|
+
Object.defineProperty(element, 'name', slot)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**`Task` carries an `AbortSignal`**
|
|
84
|
+
- The second argument to the Task callback is an `AbortSignal`
|
|
85
|
+
- The signal is aborted when the Task's dependencies change before the previous async operation completes
|
|
86
|
+
- Always pass it to any `fetch` or cancellable async operation inside a Task
|
|
87
|
+
</callback_patterns>
|
|
88
|
+
|
|
89
|
+
<lifecycle_summary>
|
|
90
|
+
| Function | Must be in owner? | Returns | Re-runs on dependency change? |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
| `createScope(fn)` | No | `Cleanup` | No (fn runs once) |
|
|
93
|
+
| `createEffect(fn)` | **Yes** | `Cleanup` | Yes |
|
|
94
|
+
| `createMemo(fn)` | No | `Memo<T>` | Lazily (on read) |
|
|
95
|
+
| `createTask(fn)` | No | `Task<T>` | Yes (async) |
|
|
96
|
+
</lifecycle_summary>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
Error classes thrown by @zeix/cause-effect and the conditions that trigger them. Read this when writing error-handling code, testing error conditions, or diagnosing an unexpected throw.
|
|
3
|
+
</overview>
|
|
4
|
+
|
|
5
|
+
<error_table>
|
|
6
|
+
| Class | When thrown |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `NullishSignalValueError` | Signal value is `null` or `undefined` |
|
|
9
|
+
| `InvalidSignalValueError` | Value fails the `guard` predicate |
|
|
10
|
+
| `InvalidCallbackError` | A required callback argument is not a function |
|
|
11
|
+
| `DuplicateKeyError` | List/Collection key collision on insert |
|
|
12
|
+
| `UnsetSignalValueError` | Reading a Sensor or Task before it has produced its first value |
|
|
13
|
+
| `ReadonlySignalError` | Attempting to write to a read-only signal |
|
|
14
|
+
| `RequiredOwnerError` | `createEffect` called outside an owner (effect or scope) |
|
|
15
|
+
| `CircularDependencyError` | A cycle is detected in the reactive graph |
|
|
16
|
+
|
|
17
|
+
All error classes are defined in `src/errors.ts`.
|
|
18
|
+
</error_table>
|
|
19
|
+
|
|
20
|
+
<error_details>
|
|
21
|
+
|
|
22
|
+
<NullishSignalValueError>
|
|
23
|
+
Thrown when a signal's value is `null` or `undefined`. Because all signal generics use `T extends {}`, nullish values are excluded by type — this error surfaces the constraint at runtime for cases where type safety is bypassed (e.g. untyped interop, type assertions).
|
|
24
|
+
</NullishSignalValueError>
|
|
25
|
+
|
|
26
|
+
<InvalidSignalValueError>
|
|
27
|
+
Thrown when a value passed to `set()` fails the `guard` predicate supplied in the signal's options. This is the runtime enforcement of custom type narrowing at signal boundaries.
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
const age = createState(0, {
|
|
31
|
+
guard: (v): v is number => typeof v === 'number' && v >= 0,
|
|
32
|
+
})
|
|
33
|
+
age.set(-1) // throws InvalidSignalValueError
|
|
34
|
+
```
|
|
35
|
+
</InvalidSignalValueError>
|
|
36
|
+
|
|
37
|
+
<InvalidCallbackError>
|
|
38
|
+
Thrown when a required callback argument (e.g. the computation function passed to `createMemo`, `createTask`, or `createEffect`) is not a function. Catches programming errors like passing `undefined` or a non-function value.
|
|
39
|
+
</InvalidCallbackError>
|
|
40
|
+
|
|
41
|
+
<DuplicateKeyError>
|
|
42
|
+
Thrown when inserting an item into a List or Collection whose key already exists. Keys must be unique within a given List or Collection. Use `update()` or `set()` to change an existing entry instead.
|
|
43
|
+
</DuplicateKeyError>
|
|
44
|
+
|
|
45
|
+
<UnsetSignalValueError>
|
|
46
|
+
Thrown when `.get()` is called on a Sensor or Task before it has emitted its first value. Unlike State, Sensor and Task have no initial value — they start in an explicitly unset state.
|
|
47
|
+
|
|
48
|
+
Handle this with `match`, which provides a `nil` branch for the unset case:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
match([sensor, task], {
|
|
52
|
+
ok: ([s, t]) => render(s, t),
|
|
53
|
+
nil: () => showSpinner(),
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
</UnsetSignalValueError>
|
|
57
|
+
|
|
58
|
+
<ReadonlySignalError>
|
|
59
|
+
Thrown when code attempts to call `.set()` on a signal that was created as or converted to a read-only signal. Derived signals (Memo, Task) are inherently read-only; certain factory options can also produce read-only State or Sensor instances.
|
|
60
|
+
</ReadonlySignalError>
|
|
61
|
+
|
|
62
|
+
<RequiredOwnerError>
|
|
63
|
+
Thrown when `createEffect` is called without an active owner in the current execution context. Effects must be created inside a `createScope` callback or inside another `createEffect` callback so that their cleanup can be registered.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// Wrong — no active owner
|
|
67
|
+
createEffect(() => console.log('runs')) // throws RequiredOwnerError
|
|
68
|
+
|
|
69
|
+
// Correct — wrapped in a scope
|
|
70
|
+
const dispose = createScope(() => {
|
|
71
|
+
createEffect(() => console.log('runs'))
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
</RequiredOwnerError>
|
|
75
|
+
|
|
76
|
+
<CircularDependencyError>
|
|
77
|
+
Thrown when the graph engine detects a cycle during propagation — a signal that, directly or transitively, depends on itself. Cycles make it impossible to determine a stable evaluation order and are always a programming error.
|
|
78
|
+
|
|
79
|
+
Common cause: a Memo or Task that writes to a State it also reads, or two Memos that read each other.
|
|
80
|
+
</CircularDependencyError>
|
|
81
|
+
|
|
82
|
+
</error_details>
|
|
83
|
+
|
|
84
|
+
<testing_error_conditions>
|
|
85
|
+
Use `expect(() => ...).toThrow(ErrorClass)` to assert that a specific error is thrown:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { InvalidSignalValueError, createState } from '@zeix/cause-effect'
|
|
89
|
+
|
|
90
|
+
test('rejects negative age', () => {
|
|
91
|
+
const age = createState(0, { guard: (v): v is number => typeof v === 'number' && v >= 0 })
|
|
92
|
+
expect(() => age.set(-1)).toThrow(InvalidSignalValueError)
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Import error classes directly from `src/errors.ts` in internal tests, or from the package root in consumer-facing tests.
|
|
97
|
+
</testing_error_conditions>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
Internal node shapes and global pointers in the @zeix/cause-effect graph engine. Read this when working on graph propagation, ownership, or cleanup.
|
|
3
|
+
</overview>
|
|
4
|
+
|
|
5
|
+
<node_shapes>
|
|
6
|
+
Five node shapes are used internally. Source files are authoritative — these are summaries only.
|
|
7
|
+
|
|
8
|
+
| Shape | Role | File |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `StateNode<T>` | Source only — holds a value, no dependencies | `src/nodes/state.ts` |
|
|
11
|
+
| `MemoNode<T>` | Source + sink — derives a value from dependencies | `src/nodes/memo.ts` |
|
|
12
|
+
| `TaskNode<T>` | Source + sink + `AbortController` — async derivation with cancellation | `src/nodes/task.ts` |
|
|
13
|
+
| `EffectNode` | Sink + owner — runs side effects, owns child effects/scopes | `src/nodes/effect.ts` |
|
|
14
|
+
| `Scope` | Owner only — groups cleanup registrations, no reactive tracking | `src/graph.ts` |
|
|
15
|
+
|
|
16
|
+
`Slot`, `Store`, `List`, and `Collection` are built on top of `MemoNode<T>` internally.
|
|
17
|
+
</node_shapes>
|
|
18
|
+
|
|
19
|
+
<global_pointers>
|
|
20
|
+
Two independent global pointers are maintained by `src/graph.ts`:
|
|
21
|
+
|
|
22
|
+
**`activeSink`**
|
|
23
|
+
- Set to the currently-running Memo, Task, or Effect during its computation
|
|
24
|
+
- Any signal read while `activeSink` is set records a dependency edge to it
|
|
25
|
+
- Nulled by `untrack(fn)` — reads inside `fn` do not create edges
|
|
26
|
+
- Reset to `null` after each computation completes
|
|
27
|
+
|
|
28
|
+
**`activeOwner`**
|
|
29
|
+
- Set to the currently-running Effect or Scope
|
|
30
|
+
- Any cleanup registered while `activeOwner` is set is attached to that owner
|
|
31
|
+
- Nulled by `unown(fn)` — cleanups registered inside `fn` are not owned by the current context
|
|
32
|
+
- Use `unown` in `connectedCallback` for nodes whose lifecycle is managed by the DOM, not by the reactive graph
|
|
33
|
+
</global_pointers>
|
|
34
|
+
|
|
35
|
+
<ownership_vs_tracking>
|
|
36
|
+
The two pointers are independent and serve different purposes:
|
|
37
|
+
|
|
38
|
+
| Pointer | Purpose | Nulled by |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `activeSink` | Dependency tracking (what re-runs when a source changes) | `untrack()` |
|
|
41
|
+
| `activeOwner` | Cleanup registration (what is disposed when an owner is disposed) | `unown()` |
|
|
42
|
+
|
|
43
|
+
A computation can track dependencies without owning cleanups, and vice versa. These are not the same concept.
|
|
44
|
+
</ownership_vs_tracking>
|
|
45
|
+
|
|
46
|
+
<flag_semantics>
|
|
47
|
+
Propagation is driven by bitmask flags defined in `src/graph.ts`. Read that file and `ARCHITECTURE.md` for the full semantics. Key flags:
|
|
48
|
+
|
|
49
|
+
- `FLAG_DIRTY` — node's value is stale and must recompute
|
|
50
|
+
- `FLAG_CHECK` — node may be stale; check sources before deciding whether to recompute
|
|
51
|
+
- `FLAG_NOTIFIED` — node has already been scheduled in the current flush
|
|
52
|
+
|
|
53
|
+
`FLAG_CHECK` is how `equals` suppresses subtrees: when a Memo recomputes to the same value, downstream nodes are marked `FLAG_CHECK` instead of `FLAG_DIRTY`, and they skip recomputation if their sources have not actually changed.
|
|
54
|
+
</flag_semantics>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
Counterintuitive behaviors in @zeix/cause-effect that commonly cause bugs or confusion. Read this when debugging unexpected reactive behavior, or when writing tests that cover edge cases.
|
|
3
|
+
</overview>
|
|
4
|
+
|
|
5
|
+
<direct_lookups_do_not_track>
|
|
6
|
+
**`byKey()`, `at()`, `keyAt()`, and `indexOfKey()` do not create graph edges.** They are direct lookups into the internal map/array — calling them inside an effect or memo does not subscribe to structural changes.
|
|
7
|
+
|
|
8
|
+
To react to structural changes (key added, key removed, order changed), read a tracking accessor instead:
|
|
9
|
+
|
|
10
|
+
| You want to react to | Read this |
|
|
11
|
+
|---|---|
|
|
12
|
+
| Any structural change | `collection.get()` or `list.get()` |
|
|
13
|
+
| Key set membership | `collection.keys()` |
|
|
14
|
+
| Length / item count | `collection.length` |
|
|
15
|
+
| A specific item's value | `collection.get()` then access the item |
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// Wrong — effect does not re-run when keys are added or removed
|
|
19
|
+
createEffect(() => {
|
|
20
|
+
const item = collection.byKey('id-123')
|
|
21
|
+
render(item)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Correct — reading keys() creates a dependency on structural changes
|
|
25
|
+
createEffect(() => {
|
|
26
|
+
const keys = collection.keys() // tracks structure
|
|
27
|
+
const item = collection.byKey('id-123') // safe after establishing the edge
|
|
28
|
+
render(item)
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
</direct_lookups_do_not_track>
|
|
32
|
+
|
|
33
|
+
<conditional_reads_delay_watched>
|
|
34
|
+
**Conditional signal reads delay `watched` activation.** The `watched` callback on a State or Sensor fires when the first downstream effect subscribes. If a signal is only read inside a branch that hasn't executed yet, `watched` does not fire until that branch runs.
|
|
35
|
+
|
|
36
|
+
Read all signals you care about eagerly — before any conditional logic — to ensure `watched` fires on the first effect run:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// Bad — `derived` is only read after `task` resolves to `ok`
|
|
40
|
+
// `derived.watched` does not fire until the task has a value
|
|
41
|
+
createEffect(() => {
|
|
42
|
+
match([task], {
|
|
43
|
+
ok: ([result]) => render(derived.get(), result),
|
|
44
|
+
nil: () => showSpinner(),
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Good — both signals are read on every run, regardless of task state
|
|
49
|
+
// Both `watched` callbacks fire immediately when the effect is created
|
|
50
|
+
createEffect(() => {
|
|
51
|
+
match([task, derived], {
|
|
52
|
+
ok: ([result, value]) => render(value, result),
|
|
53
|
+
nil: () => showSpinner(),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This also applies to plain `if` / ternary / `&&` patterns — any signal read gated behind a condition may not establish its dependency edge until the condition is true.
|
|
59
|
+
</conditional_reads_delay_watched>
|
|
60
|
+
|
|
61
|
+
<equals_suppresses_subtrees>
|
|
62
|
+
**`equals` suppresses entire downstream subgraphs, not just the node it is set on.** When a Memo or State recomputes to a value that is `equals` to the previous one, all downstream nodes receive `FLAG_CHECK` instead of `FLAG_DIRTY`. Those nodes skip recomputation entirely without running their callbacks.
|
|
63
|
+
|
|
64
|
+
This is a powerful optimization, but it has a non-obvious consequence: a custom `equals` on an intermediate Memo can silently prevent large parts of the graph from updating, even if upstream sources changed.
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
const source = createState({ x: 1, y: 2 })
|
|
68
|
+
|
|
69
|
+
// This memo compares by x only
|
|
70
|
+
const xOnly = createMemo(
|
|
71
|
+
() => source.get().x,
|
|
72
|
+
{ equals: (a, b) => a === b }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// This effect depends on xOnly
|
|
76
|
+
// It will NOT re-run if source changes but x stays the same,
|
|
77
|
+
// even if y changed dramatically
|
|
78
|
+
createEffect(() => {
|
|
79
|
+
console.log('x is', xOnly.get())
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
When debugging "why did my effect not re-run", check for custom `equals` on intermediate memos in the dependency chain.
|
|
84
|
+
</equals_suppresses_subtrees>
|
|
85
|
+
|
|
86
|
+
<watched_stable_through_mutations>
|
|
87
|
+
**`watched` stays active through structural mutations.** The `watched` callback on a List or Collection source is called once when the first downstream effect subscribes, and `unwatched` is called when the last downstream effect unsubscribes. Structural mutations (adding items, removing items, updating values) do not call `unwatched` and then `watched` again — the callback remains active for the lifetime of the subscription.
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
const list = createList(
|
|
91
|
+
() => fetchItems(), // watched: start polling / open WebSocket
|
|
92
|
+
() => stopPolling(), // unwatched: stop polling / close WebSocket
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// Adding or removing items from the list does NOT restart the watched/unwatched cycle.
|
|
96
|
+
// The data source stays open as long as at least one effect is subscribed.
|
|
97
|
+
list.push({ id: '1', name: 'Item 1' }) // watched callback is NOT called again
|
|
98
|
+
list.delete('1') // watched callback is NOT called again
|
|
99
|
+
```
|
|
100
|
+
</watched_stable_through_mutations>
|
|
101
|
+
|
|
102
|
+
<task_abort_on_dependency_change>
|
|
103
|
+
**A Task's `AbortSignal` is aborted when dependencies change before the async operation completes.** If a Task's sources update while the previous `Promise` is still pending, a new run is scheduled and the previous `AbortController` is aborted. Not forwarding the signal to cancellable async operations will cause stale results to overwrite fresh ones.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// Wrong — fetch is not cancellable; stale response may arrive after a newer one
|
|
107
|
+
const results = createTask(async () => {
|
|
108
|
+
return fetch(`/api/search?q=${query.get()}`).then(r => r.json())
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Correct — abort signal forwarded; stale requests are cancelled
|
|
112
|
+
const results = createTask(async (prev, signal) => {
|
|
113
|
+
return fetch(`/api/search?q=${query.get()}`, { signal }).then(r => r.json())
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
</task_abort_on_dependency_change>
|
|
117
|
+
|
|
118
|
+
<sensor_unset_before_first_value>
|
|
119
|
+
**Reading a Sensor or Task before it has produced a value throws `UnsetSignalValueError`.** Unlike State, these signals have no initial value — they are explicitly "unset" until the first value arrives.
|
|
120
|
+
|
|
121
|
+
Guard against this with `match` or by checking the signal's status before reading:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const sensor = createSensor(set => {
|
|
125
|
+
const id = setInterval(() => set(Date.now()), 1000)
|
|
126
|
+
return () => clearInterval(id)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Wrong — throws UnsetSignalValueError on first run, before the interval fires
|
|
130
|
+
createEffect(() => {
|
|
131
|
+
console.log(sensor.get())
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Correct — match handles the nil (unset) case explicitly
|
|
135
|
+
createEffect(() => {
|
|
136
|
+
match([sensor], {
|
|
137
|
+
ok: ([timestamp]) => console.log(timestamp),
|
|
138
|
+
nil: () => console.log('waiting for first value…'),
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
</sensor_unset_before_first_value>
|
|
143
|
+
|
|
144
|
+
<scope_cleanup_is_synchronous>
|
|
145
|
+
**Scope and Effect cleanup runs synchronously when the returned `Cleanup` function is called.** It does not wait for the current flush to complete. Calling cleanup during a flush (e.g. inside a batch callback) is safe but will immediately dispose the owner and all its children.
|
|
146
|
+
</scope_cleanup_is_synchronous>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<overview>
|
|
2
|
+
Where to find things in the @zeix/cause-effect codebase. Read this before locating any source file.
|
|
3
|
+
</overview>
|
|
4
|
+
|
|
5
|
+
<authoritative_documents>
|
|
6
|
+
| What you need | Where to look |
|
|
7
|
+
|---|---|
|
|
8
|
+
| Vision, audience, constraints, non-goals | `REQUIREMENTS.md` |
|
|
9
|
+
| Mental model, non-obvious behaviors, TS constraints | `CLAUDE.md` |
|
|
10
|
+
| Full API reference with examples | `README.md` |
|
|
11
|
+
| Mapping from React/Vue/Angular patterns; when to use each signal type | `GUIDE.md` |
|
|
12
|
+
| Graph engine architecture, node shapes, propagation | `ARCHITECTURE.md` |
|
|
13
|
+
| Public API surface (all exports, types) | `index.ts` |
|
|
14
|
+
| Core graph engine (flags, propagation, flush, ownership) | `src/graph.ts` |
|
|
15
|
+
| Error classes | `src/errors.ts` |
|
|
16
|
+
| Signal base types and type guards | `src/signal.ts` |
|
|
17
|
+
| Shared utilities | `src/util.ts` |
|
|
18
|
+
</authoritative_documents>
|
|
19
|
+
|
|
20
|
+
<signal_source_files>
|
|
21
|
+
Each signal type lives in its own file under `src/nodes/`:
|
|
22
|
+
|
|
23
|
+
| Signal | File | Factory | Type guard |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| State | `src/nodes/state.ts` | `createState()` | `isState()` |
|
|
26
|
+
| Sensor | `src/nodes/sensor.ts` | `createSensor()` | `isSensor()` |
|
|
27
|
+
| Memo | `src/nodes/memo.ts` | `createMemo()` | `isMemo()` |
|
|
28
|
+
| Task | `src/nodes/task.ts` | `createTask()` | `isTask()` |
|
|
29
|
+
| Effect | `src/nodes/effect.ts` | `createEffect()` | — |
|
|
30
|
+
| Slot | `src/nodes/slot.ts` | `createSlot()` | `isSlot()` |
|
|
31
|
+
| Store | `src/nodes/store.ts` | `createStore()` | `isStore()` |
|
|
32
|
+
| List | `src/nodes/list.ts` | `createList()` | `isList()` |
|
|
33
|
+
| Collection | `src/nodes/collection.ts` | `createCollection()` / `deriveCollection()` | `isCollection()` |
|
|
34
|
+
|
|
35
|
+
`match()` and `MatchHandlers` live in `src/nodes/effect.ts` alongside `createEffect`.
|
|
36
|
+
</signal_source_files>
|
|
37
|
+
|
|
38
|
+
<quick_lookup>
|
|
39
|
+
- Changing a signal's public API → read the signal's source file + `index.ts` + the relevant section of `README.md`
|
|
40
|
+
- Changing graph traversal, flags, or flush order → read `src/graph.ts` + `ARCHITECTURE.md`
|
|
41
|
+
- Adding or changing error conditions → read `src/errors.ts`
|
|
42
|
+
- Adding a shared utility → check `src/util.ts` first; add there if it belongs to multiple nodes
|
|
43
|
+
- Checking type constraints or TS-specific decisions → read `CLAUDE.md`
|
|
44
|
+
- Verifying a feature is in scope → read `REQUIREMENTS.md`
|
|
45
|
+
</quick_lookup>
|