Skip to content

Commit 2dc2acf

Browse files
Merge pull request #28 from zeixcom/next
Bugfix for propagation to effects (FLAG_CHECK first)
2 parents 7aef9a9 + 29cdc3a commit 2dc2acf

File tree

13 files changed

+249
-36
lines changed

13 files changed

+249
-36
lines changed

ARCHITECTURE.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,14 @@ The first four flags (`CLEAN`/`CHECK`/`DIRTY`/`RUNNING`) are used by the core gr
106106

107107
`FLAG_RELINK` is used exclusively by composite signal types (Store, List, Collection, deriveCollection) that manage their own child signals. When a structural mutation adds or removes child signals, the node is flagged `FLAG_DIRTY | FLAG_RELINK`. On the next `get()`, the composite signal's fast path reads the flag: if `FLAG_RELINK` is set, it forces a tracked `refresh()` after rebuilding the value so that `recomputeMemo()` can call `link()` for new child signals and `trimSources()` for removed ones. This avoids the previous approach of nulling `node.sources`/`node.sourcesTail`, which orphaned edges in upstream sink lists. `FLAG_RELINK` is always cleared by `recomputeMemo()`, which assigns `node.flags = FLAG_RUNNING` (clearing all bits) at the start of recomputation.
108108

109-
### The `propagate(node)` Function
109+
### The `propagate(node, newFlag?)` Function
110110

111-
When a source value changes, `propagate()` walks its sink list:
111+
When a source value changes, `propagate()` walks its sink list. The `newFlag` parameter defaults to `FLAG_DIRTY` but callers may pass `FLAG_CHECK` for speculative invalidation (e.g., watched callbacks where the source value may not have actually changed).
112112

113-
- **Memo/Task sinks** (have `sinks` field): Flagged `DIRTY`. Their own sinks are recursively flagged `CHECK`. If the node has an in-flight `AbortController`, it is aborted immediately.
114-
- **Effect sinks** (no `sinks` field): Flagged `DIRTY` and pushed onto the `queuedEffects` array for later execution.
113+
- **Memo/Task sinks** (have `sinks` field): Flagged with `newFlag` (typically `DIRTY`). Their own sinks are recursively flagged `CHECK`. If the node has an in-flight `AbortController`, it is aborted immediately. Short-circuits if the node already carries an equal or higher flag.
114+
- **Effect sinks** (no `sinks` field): Flagged with `newFlag` and pushed onto the `queuedEffects` array. An effect is only enqueued once — subsequent propagations escalate the flag (e.g., `CHECK``DIRTY`) without re-enqueuing. The flag is assigned (not OR'd) to clear `FLAG_RUNNING`, preserving the existing pattern where a state update inside a running effect triggers a re-run.
115115

116-
The two-level flagging (`DIRTY` for direct dependents, `CHECK` for transitive) avoids unnecessary recomputation. A `CHECK` node only recomputes if, upon inspection during `refresh()`, one of its sources turns out to have actually changed.
116+
The two-level flagging (`DIRTY` for direct dependents, `CHECK` for transitive) avoids unnecessary recomputation. A `CHECK` node only recomputes if, upon inspection during `refresh()`, one of its sources turns out to have actually changed. This applies equally to memo, task, and effect nodes.
117117

118118
### The `refresh(node)` Function
119119

@@ -141,7 +141,7 @@ If `FLAG_RUNNING` is encountered, a `CircularDependencyError` is thrown.
141141

142142
### The `flush()` Function
143143

144-
`flush()` iterates over `queuedEffects`, calling `refresh()` on each effect that is still `DIRTY`. A `flushing` guard prevents re-entrant flushes. Effects that were enqueued during the flush (due to async resolution or nested state changes) are processed in the same pass, since `flush()` reads the array length dynamically.
144+
`flush()` iterates over `queuedEffects`, calling `refresh()` on each effect that is still `DIRTY` or `CHECK`. A `flushing` guard prevents re-entrant flushes. Effects that were enqueued during the flush (due to async resolution or nested state changes) are processed in the same pass, since `flush()` reads the array length dynamically. Effects with only `FLAG_CHECK` enter `refresh()`, which walks their sources — if no source value actually changed, the effect is cleaned without running.
145145

146146
### Effect Lifecycle
147147

@@ -209,7 +209,7 @@ The `error` field preserves thrown errors: if `fn` throws, the error is stored a
209209

210210
**Reducer pattern**: The `prev` parameter enables state accumulation across recomputations without writable state.
211211

212-
**Watched lifecycle**: An optional `watched` callback in options provides lazy external invalidation. The callback receives an `invalidate` function and is invoked on first sink attachment. Calling `invalidate()` marks the node `FLAG_DIRTY`, propagates to sinks, and flushes — triggering re-evaluation of the memo's `fn` without changing any tracked dependency. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where the memo re-derives its value in response to external events.
212+
**Watched lifecycle**: An optional `watched` callback in options provides lazy external invalidation. The callback receives an `invalidate` function and is invoked on first sink attachment. Calling `invalidate()` calls `propagate(node)` on the memo itself, which marks it `FLAG_DIRTY` and propagates `FLAG_CHECK` to downstream sinks, then flushes. During flush, downstream effects verify the memo via `refresh()` — if the memo's `equals` function determines the recomputed value is unchanged, the effect is cleaned without running. The returned cleanup is stored as `node.stop` and called when the last sink detaches. This enables patterns like DOM observation (MutationObserver) where a memo re-derives its value in response to external events, with the `equals` check respected at every level of the graph.
213213

214214
### Task (`src/nodes/task.ts`)
215215

@@ -221,7 +221,7 @@ During dependency tracking, only the synchronous preamble of `fn` is tracked (be
221221

222222
`isPending()` returns `true` while a computation is in flight. `abort()` cancels the current computation manually. Errors are preserved like Memo, but old values are retained on errors (the last successful result remains accessible).
223223

224-
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()` marks the node dirty and triggers re-execution, which aborts any in-flight computation via the existing `AbortController` mechanism before starting a new one.
224+
**Watched lifecycle**: Same pattern as Memo — an optional `watched` callback receives `invalidate` and is invoked on first sink attachment. Calling `invalidate()` calls `propagate(node)` on the task itself, which marks it dirty, aborts any in-flight computation eagerly via the `AbortController`, and propagates `FLAG_CHECK` to downstream sinks. Effects only re-run if the task's resolved value actually changes.
225225

226226
### Effect (`src/nodes/effect.ts`)
227227

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 0.18.4
4+
5+
### Fixed
6+
7+
- **Watched `invalidate()` now respects `equals` at every graph level**: Previously, calling `invalidate()` from a Memo or Task `watched` callback propagated `FLAG_DIRTY` directly to effect sinks, causing unconditional re-runs even when the recomputed value was unchanged. Now `invalidate()` delegates to `propagate(node)`, which marks the node itself `FLAG_DIRTY` and propagates `FLAG_CHECK` to downstream sinks. During flush, effects verify their sources via `refresh()` — if the memo's `equals` function determines the value is unchanged, the effect is cleaned without running. This eliminates unnecessary effect executions for watched memos with custom equality or stable return values.
8+
9+
### Changed
10+
11+
- **`propagate()` supports `FLAG_CHECK` for effect nodes**: The effect branch of `propagate()` now respects the `newFlag` parameter instead of unconditionally setting `FLAG_DIRTY`. Effects are enqueued only on first notification; subsequent propagations escalate the flag (e.g., `CHECK``DIRTY`) without re-enqueuing.
12+
- **`flush()` processes `FLAG_CHECK` effects**: The flush loop now calls `refresh()` on effects with either `FLAG_DIRTY` or `FLAG_CHECK`, enabling the check-sources-first path for effects.
13+
- **Task `invalidate()` aborts eagerly**: Task watched callbacks now abort in-flight computations immediately during `propagate()` rather than deferring to `recomputeTask()`, consistent with the normal dependency-change path.
14+
315
## 0.18.3
416

517
### Added

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ The generic constraint `T extends {}` is crucial - it excludes `null` and `undef
126126
127127
Computed signals implement smart memoization:
128128
- **Dependency Tracking**: Automatically tracks which signals are accessed during computation via `link()`
129-
- **Stale Detection**: Flag-based dirty checking (CLEAN, CHECK, DIRTY) — only recalculates when dependencies actually change
129+
- **Stale Detection**: Flag-based dirty checking (CLEAN, CHECK, DIRTY) — only recalculates when dependencies actually change. The `equals` check is respected at every graph level: when a memo recomputes to the same value, downstream memos *and* effects are cleaned without running.
130130
- **Async Support**: `createTask` handles Promise-based computations with automatic AbortController cancellation
131131
- **Error Handling**: Preserves error states and prevents cascade failures
132132
- **Reducer Capabilities**: Access to previous value enables state accumulation and transitions
@@ -274,7 +274,7 @@ const processed = todoList
274274
**Memo (`createMemo`)**:
275275
- Synchronous derived computations with memoization
276276
- Reducer pattern with previous value access
277-
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver)
277+
- Optional `watched(invalidate)` callback for lazy external invalidation (e.g., MutationObserver). Calling `invalidate()` marks the memo dirty and propagates `FLAG_CHECK` to sinks — effects only re-run if the memo's `equals` check determines the value actually changed.
278278
279279
```typescript
280280
const doubled = createMemo(() => count.get() * 2)
@@ -295,7 +295,7 @@ const runningTotal = createMemo(prev => prev + currentValue.get(), { value: 0 })
295295
296296
**Task (`createTask`)**:
297297
- Async computations with automatic cancellation
298-
- Optional `watched(invalidate)` callback for lazy external invalidation
298+
- Optional `watched(invalidate)` callback for lazy external invalidation. Calling `invalidate()` eagerly aborts any in-flight computation and propagates `FLAG_CHECK` to sinks.
299299
300300
```typescript
301301
const userData = createTask(async (prev, abort) => {

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Cause & Effect
22

3-
Version 0.18.3
3+
Version 0.18.4
44

55
**Cause & Effect** is a reactive state management primitives library for TypeScript. It provides the foundational building blocks for managing complex, dynamic, composite, and asynchronous state — correctly and performantly — in a unified signal graph.
66

index.dev.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,12 @@ function propagate(node, newFlag = FLAG_DIRTY) {
199199
for (let e = node.sinks;e; e = e.nextSink)
200200
propagate(e.sink, FLAG_CHECK);
201201
} else {
202-
if (flags & FLAG_DIRTY)
202+
if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag)
203203
return;
204-
node.flags = FLAG_DIRTY;
205-
queuedEffects.push(node);
204+
const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK);
205+
node.flags = newFlag;
206+
if (!wasQueued)
207+
queuedEffects.push(node);
206208
}
207209
}
208210
function setState(node, next) {
@@ -354,7 +356,7 @@ function flush() {
354356
try {
355357
for (let i = 0;i < queuedEffects.length; i++) {
356358
const effect = queuedEffects[i];
357-
if (effect.flags & FLAG_DIRTY)
359+
if (effect.flags & (FLAG_DIRTY | FLAG_CHECK))
358360
refresh(effect);
359361
}
360362
queuedEffects.length = 0;
@@ -797,9 +799,7 @@ function createMemo(fn, options) {
797799
if (activeSink) {
798800
if (!node.sinks)
799801
node.stop = watched(() => {
800-
node.flags |= FLAG_DIRTY;
801-
for (let e = node.sinks;e; e = e.nextSink)
802-
propagate(e.sink);
802+
propagate(node);
803803
if (batchDepth === 0)
804804
flush();
805805
});
@@ -848,9 +848,7 @@ function createTask(fn, options) {
848848
if (activeSink) {
849849
if (!node.sinks)
850850
node.stop = watched(() => {
851-
node.flags |= FLAG_DIRTY;
852-
for (let e = node.sinks;e; e = e.nextSink)
853-
propagate(e.sink);
851+
propagate(node);
854852
if (batchDepth === 0)
855853
flush();
856854
});

index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @name Cause & Effect
3-
* @version 0.18.3
3+
* @version 0.18.4
44
* @author Esther Brunner
55
*/
66

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zeix/cause-effect",
3-
"version": "0.18.3",
3+
"version": "0.18.4",
44
"author": "Esther Brunner",
55
"type": "module",
66
"main": "index.js",

src/graph.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,12 @@ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void {
292292
for (let e = node.sinks; e; e = e.nextSink)
293293
propagate(e.sink, FLAG_CHECK)
294294
} else {
295-
if (flags & FLAG_DIRTY) return
295+
if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return
296296

297297
// Enqueue effect for later execution
298-
node.flags = FLAG_DIRTY
299-
queuedEffects.push(node as EffectNode)
298+
const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK)
299+
node.flags = newFlag
300+
if (!wasQueued) queuedEffects.push(node as EffectNode)
300301
}
301302
}
302303

@@ -471,7 +472,7 @@ function flush(): void {
471472
try {
472473
for (let i = 0; i < queuedEffects.length; i++) {
473474
const effect = queuedEffects[i]
474-
if (effect.flags & FLAG_DIRTY) refresh(effect)
475+
if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) refresh(effect)
475476
}
476477
queuedEffects.length = 0
477478
} finally {
@@ -601,6 +602,7 @@ export {
601602
createScope,
602603
DEFAULT_EQUALITY,
603604
SKIP_EQUALITY,
605+
FLAG_CHECK,
604606
FLAG_CLEAN,
605607
FLAG_DIRTY,
606608
FLAG_RELINK,

src/nodes/memo.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,7 @@ function createMemo<T extends {}>(
110110
if (activeSink) {
111111
if (!node.sinks)
112112
node.stop = watched(() => {
113-
node.flags |= FLAG_DIRTY
114-
for (let e = node.sinks; e; e = e.nextSink)
115-
propagate(e.sink)
113+
propagate(node as unknown as SinkNode)
116114
if (batchDepth === 0) flush()
117115
})
118116
link(node, activeSink)

0 commit comments

Comments
 (0)