Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d81b8e2
fix(effect): contain flush errors and bound effect convergence
estherbrunner Jul 10, 2026
7a64ab8
docs: add runtime-verified improvement plans
estherbrunner Jul 10, 2026
b20c434
Merge pull request #62 from zeixcom/bugfix/flush-error-containment
estherbrunner Jul 10, 2026
4af21d3
fix(list): untrack internal reads in mutation methods
estherbrunner Jul 10, 2026
00f3f3a
Merge pull request #63 from zeixcom/bugfix/list-mutation-tracking-leaks
estherbrunner Jul 10, 2026
2ff1c05
fix(store): guard proxy against direct assignment, deletion, and defi…
estherbrunner Jul 10, 2026
c999f5c
Merge pull request #64 from zeixcom/bugfix/store-proxy-write-guard
estherbrunner Jul 10, 2026
f4d8ae4
style: align Biome config with codebase conventions
estherbrunner Jul 10, 2026
9a3f185
ci: add CI workflow and gate npm publish on test suite
estherbrunner Jul 10, 2026
f200289
Merge pull request #65 from zeixcom/feature/ci-workflow
estherbrunner Jul 10, 2026
c8ab31c
chore: update biome and fix formatting
estherbrunner Jul 10, 2026
b65bf51
Delete PLAN-ci-workflow.md
estherbrunner Jul 10, 2026
820c0ff
fix(package): correct npm packaging for exports, tree-shaking, and pe…
estherbrunner Jul 10, 2026
a2673a0
chore: prepare release 1.4.0
estherbrunner Jul 10, 2026
e12e350
Merge pull request #66 from zeixcom/feature/package-exports
estherbrunner Jul 10, 2026
48564a3
Update ci.yml
estherbrunner Jul 10, 2026
a38b612
Merge branch 'next' into release/1.4.0
estherbrunner Jul 10, 2026
8f0752d
Merge pull request #68 from zeixcom/release/1.4.0
estherbrunner Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/adr-keeper/references/adr-index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ADR Index

**Last updated:** 2026-06-24
**Total ADRs:** 16
**Last updated:** 2026-07-10
**Total ADRs:** 17

| # | ADR | Status | Related Requirements |
|---|-----|--------|---------------------|
Expand All @@ -21,6 +21,7 @@
| [0014](../../../../adr/0014-two-path-access-pattern-for-composite-signals.md) | Two-Path Access Pattern for Composite Signals | ✅ Accepted | Performance |
| [0015](../../../../adr/0015-composite-lookup-methods-track-structural-changes.md) | Composite Lookup Methods Track Structural Changes (Asymmetrically) | ✅ Accepted | Explicit Reactivity, Non-Nullable Types, Minimal Surface |
| [0016](../../../../adr/0016-path-scoped-cycle-detection-in-deep-equality.md) | Path-Scoped Cycle Detection in Deep Equality | ✅ Accepted | Performance |
| [0017](../../../../adr/0017-store-proxy-rejects-direct-writes.md) | Store Proxy Rejects Direct Writes | ✅ Accepted | Explicit Reactivity, Minimal Surface |

---

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/cause-effect/workflows/debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Match the symptom to the most likely cause before reading any code:
| `UnsetSignalValueError` thrown | Reading Sensor or Task before first value | ../shared/references/non-obvious-behaviors.md: `sensor-unset-before-first-value` |
| `RequiredOwnerError` thrown | `createEffect` called outside a `createScope` or parent effect | ../shared/references/api-facts.md: `createEffect`, `createScope` |
| `CircularDependencyError` thrown | A signal depends on itself, directly or transitively | ../shared/references/error-classes.md: `CircularDependencyError` |
| `EffectConvergenceError` thrown | Effects write to signals they depend on without ever settling (unconditional self-write or mutual effect writes) | ../shared/references/error-classes.md: `EffectConvergenceError` |
| Downstream Memo or Effect never updates despite source changing | Custom `equals` on an intermediate Memo is suppressing the subgraph | ../shared/references/non-obvious-behaviors.md: `equals-suppresses-subtrees` |
| Cleanup not running / memory leak suspected | Effect created without an active owner, or `unown` used incorrectly | ../shared/references/api-facts.md: `unown`, `createScope` |

Expand Down
20 changes: 20 additions & 0 deletions .agents/skills/shared/references/error-classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ReadonlySignalError,
RequiredOwnerError,
CircularDependencyError,
EffectConvergenceError,
PromiseValueError,
} from '@zeix/cause-effect'
```
Expand All @@ -36,6 +37,7 @@ All error classes are defined in `src/errors.ts` for library development.
| `ReadonlySignalError` | Attempting to write to a read-only signal |
| `RequiredOwnerError` | `createEffect` called outside an owner (scope or parent effect) |
| `CircularDependencyError` | A cycle is detected in the reactive graph |
| `EffectConvergenceError` | Effects keep re-triggering each other without settling (1000 flush passes) |
| `PromiseValueError` | A non-`async` Memo/Slot callback returns a `Promise` |
</error_table>

Expand Down Expand Up @@ -140,6 +142,24 @@ evaluation order and are always a programming error.
**Fix:** restructure the data flow so that values move in one direction only.
</CircularDependencyError>

<EffectConvergenceError>
Thrown when queued effects did not settle within 1000 flush passes. An effect that writes
to a signal it also depends on is re-run until the graph converges — converging writes
(clamping, normalization, write-once initialization) are allowed and the effect always
observes the final value. This error fires only when the graph can never settle.

**Common causes:**
- An effect that unconditionally writes a signal it reads: `createEffect(() => count.set(count.get() + 1))`
- Two effects that write each other's dependencies (mutual ping-pong)

The error surfaces from the `set()`/`update()`/`batch()`/`createEffect()` call that
triggered the runaway. Other queued effects still run before it is thrown (it may arrive
inside an `AggregateError` when other effects also threw).

**Fix:** make the self-write conditional so it converges, or express the derived value as a
`createMemo` instead of writing state from an effect.
</EffectConvergenceError>

<PromiseValueError>
Thrown when a Memo or Slot callback returns a `Promise`. `createComputed`/`createSignal`
decide Memo vs. Task by checking whether the callback is declared `async` — a check made
Expand Down
73 changes: 63 additions & 10 deletions .agents/skills/shared/references/non-obvious-behaviors.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,20 +198,73 @@ If you must wrap a Promise-returning API, always use the `async` keyword so the
routes it to `createTask` (with abort/pending support) rather than `createMemo`.
</async_requires_async_syntax>

<flush_has_no_loop_guard>
**`flush()` has no infinite-loop guard.** An effect that writes to a state it also reads
will re-trigger itself on every run, looping until the call stack or heap is exhausted.
This is standard behavior for fine-grained reactive systems and is intentional — the
overhead of a per-flush iteration cap is not worth it for correct graphs.
<self_writing_effects_converge_or_throw>
**An effect that writes a signal it also depends on re-runs until the graph settles — and
throws `EffectConvergenceError` if it never does.** Converging self-writes (clamping,
normalization, write-once initialization) are safe: the effect's last run always observes
the final signal value.

```typescript
// BUGinfinite loop: effect reads and writes the same state
// Safeconverges: the effect re-runs and observes the clamped value
const count = createState(0)
createEffect(() => {
count.set(count.get() + 1) // re-triggers itself forever
const v = count.get()
render(v) // last render always shows the settled value
if (v > 10) count.set(10)
})

// Error — never settles: throws EffectConvergenceError after 1000 flush passes
createEffect(() => {
count.set(count.get() + 1) // unconditional self-increment
})
```

The bound also catches cycles *between* effects (A writes a state read by B, B writes a
state read by A). The error surfaces synchronously from the `set()`/`batch()`/
`createEffect()` call that triggered the runaway; other queued effects still run first.

Self-writes remain an anti-pattern for expressing derived values — prefer `createMemo`.
Reserve them for genuine feedback like clamping user input to a valid range.
</self_writing_effects_converge_or_throw>

<store_proxy_rejects_direct_writes>
**Direct property assignment, deletion, or `Object.defineProperty` on a `Store` proxy throws
`InvalidStoreMutationError`.** The proxy has no public write path — use the reactive API
instead. This prevents silent state divergence: without the guard, `store.name = 'Bob'` writes
a raw value onto the proxy target, shadowing the child `State` signal so that `store.name`
returns the raw string while `store.get()` returns the reactive value.

```typescript
const store = createStore({ name: 'Alice' })

// ❌ Throws InvalidStoreMutationError — would silently corrupt the store
store.name = 'Bob'
delete store.name
Object.defineProperty(store, 'x', { value: 1 })
Object.assign(store, { name: 'Bob' })

// ✅ Correct reactive mutation paths
store.name.set('Bob') // single property
store.set({ name: 'Bob' }) // whole-value replacement with diffing
store.add('email', 'a@b.c') // new key
store.remove('name') // delete a key
```
</store_proxy_rejects_direct_writes>

<store_method_names_shadow_data_keys>
**A data key named like a base method (`get`, `set`, `keys`, `update`, `add`, `remove`,
`byKey`) shadows the method via proxy access.** The `get` trap checks `prop in target`
first, so it returns the base method, not the child signal. Use `store.byKey(key)` to reach
such a property — `byKey` reads directly from the internal signals map.

```typescript
type T = { get: string }
const store = createStore<T>({ get: 'value' })

store.get // () => T — the method, NOT the child State
store.byKey('get') // State<string> — the child signal via the escape hatch
```

Avoid writing to a signal that the same effect reads. If you need derived state, use a
`createMemo` instead.
</flush_has_no_loop_guard>
This is inherent to the proxy design and is not considered a bug: base methods are a small
fixed set, and `byKey` provides a reliable workaround.
</store_method_names_shadow_data_keys>
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: CI

on:
push:
branches: [main, next]
pull_request:
branches: [main, next]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Typecheck
run: bunx tsc --noEmit

- name: Lint and format
run: bunx biome check .

- name: Unit tests
run: bun run test

- name: Bundle size regression
run: bun test test/regression-bundle.test.ts

- name: Build
run: bun run build

performance:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
runs-on: ubuntu-latest
continue-on-error: true # informational only — shared runners are too noisy for timing assertions
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Performance regression (informational)
run: bun test test/regression-performance.test.ts
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
3 changes: 3 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
- name: Install all dependencies (including devDependencies for build)
run: bun install

- name: Typecheck, lint, test, and bundle regression
run: bun run check

- name: Build package
run: bun run build

Expand Down
52 changes: 0 additions & 52 deletions .npmignore

This file was deleted.

16 changes: 8 additions & 8 deletions .zed/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
"language_servers": ["!eslint", "..."],
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true,
},
"source.organizeImports.biome": true
}
},
"JavaScript": {
"language_servers": ["!eslint", "..."],
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true,
},
"source.organizeImports.biome": true
}
},
"TSX": {
"language_servers": ["!eslint", "..."],
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true,
},
},
},
"source.organizeImports.biome": true
}
}
}
}
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The core flow: source change → `propagate()` flags direct sinks `DIRTY` and tr
## Effect Scheduling

- `batch(fn)`: Increments `batchDepth`; effects only flush when depth returns to 0. Batches nest.
- `flush()`: Iterates `queuedEffects`, calling `refresh()` on each still-dirty effect. A `flushing` guard prevents re-entry.
- `flush()`: Drains `queuedEffects` in passes over snapshots, calling `refresh()` on each still-dirty effect; effects re-queued during a pass (e.g. by writing their own dependencies) run in the next pass, so self-writing effects converge and always observe final values. Capped at 1000 passes — a graph that never settles throws `EffectConvergenceError`. Effect errors are collected per effect so siblings still run, then rethrown after the drain (a single error as-is, multiple wrapped in `AggregateError`). A `flushing` guard prevents re-entry; effects still flagged `RUNNING` are skipped (their own runner converges them via `scheduleEffect()`).
- Effects double as owners: child effects/scopes created during execution are disposed when the parent re-runs.

## Ownership and Cleanup
Expand Down Expand Up @@ -75,7 +75,8 @@ Return types remain honest: `byKey(k): S | undefined` etc. on List/Collection (a
| Decision | Choice | Alternatives Considered | Rationale |
|----------|--------|------------------------|-----------|
| Sync callback returning a `Promise` in Memo/Slot | Throw `PromiseValueError` in `recomputeMemo()` (`graph.ts`) the first time a non-`async` callback's return value is thenable | (a) Auto-detect ahead of time in `isAsyncFunction` and reclassify as Task; (b) leave as silent, undocumented misclassification (status quo) | Reclassifying requires invoking the callback before the Memo/Task routing decision is made, which breaks Memo's lazy-evaluation contract and is unreliable for branchy functions. Morphing a live `MemoNode` into a `TaskNode` after callers already hold a `Memo<T>` would silently change `.get()` semantics (synchronous return vs. throws-until-resolved) with no compile-time signal — unsound. A single check at the existing `recomputeMemo()` choke point catches Memo, Slot, and `createComputed`/`createSignal` misuse uniformly, costs one `typeof` check per recompute, and only fires on code that was already broken (non-breaking, "Fixed" changelog category). |
| Other 9 documented "non-obvious behaviors" (conditional reads delaying `watched`, `equals` suppressing subtrees, lazy `watched`/`unwatched` lifecycle stability, Task abort-on-change, Sensor/Task unset state, synchronous scope cleanup, no `flush` loop guard, `untrack` vs `watched` independence, `byKey().set()` vs `list.replace()`) | No code changes. Reframe as direct, predictable consequences of the dependency-tracking model in developer-facing docs rather than standalone gotchas | Changing `byKey().set()` to always propagate structurally (rejected: would force every item signal to carry a permanent edge to its list's structural node regardless of whether anything observes structurally, costing a `propagate()` traversal on every item write — conflicts with the "minimal work" performance constraint, and `list.replace()` already exists as the correct API for this case, pinned by `test/list.test.ts:261`) | Each behavior is either inherent to any correct fine-grained reactive graph (read-based edge creation, two-level dirty/check flagging, lazy lifecycle keyed on subscriber count) or an already-decided trade-off with a working escape hatch. The fix is conceptual, not code: a reader who understands the model shouldn't find these surprising. Serves REQUIREMENTS.md goal #2 (predictable mental model) without touching synchronous-path performance (goal #4). |
| Effects writing signals they depend on | Bounded convergence: `flush()` drains in passes over queue snapshots (Svelte-style), so self-writing effects re-run until settled and always observe final values; a graph that doesn't settle within 1000 passes throws `EffectConvergenceError`. `propagate()` preserves `FLAG_RUNNING` on effects and `flush()` skips mid-run effects, preventing re-entrant `runEffect` during creation-time writes | (a) Status quo (rejected: `runEffect` clobbered the effect's own dirty re-mark with `CLEAN`, so even converging clamp effects ended one run stale — rendering the pre-clamp value — and mutual effect writes looped until heap exhaustion with no guard); (b) per-effect run cap (rejected: cannot detect ping-pong between effects — each effect individually settles every pass); (c) raw queue-length cap (rejected: false-positives on wide graphs with many distinct dirty effects) | A pass cap is the only metric that is both sound and complete: a settled graph does exactly one pass regardless of effect count, while any non-converging cycle — self-loop, ping-pong, or longer — forces unbounded passes. Converging self-writes (clamping, write-once init) remain supported; non-settling graphs fail loudly at the triggering `set()` instead of silently diverging or hanging. |
| Other 8 documented "non-obvious behaviors" (conditional reads delaying `watched`, `equals` suppressing subtrees, lazy `watched`/`unwatched` lifecycle stability, Task abort-on-change, Sensor/Task unset state, synchronous scope cleanup, `untrack` vs `watched` independence, `byKey().set()` vs `list.replace()`) | No code changes. Reframe as direct, predictable consequences of the dependency-tracking model in developer-facing docs rather than standalone gotchas | Changing `byKey().set()` to always propagate structurally (rejected: would force every item signal to carry a permanent edge to its list's structural node regardless of whether anything observes structurally, costing a `propagate()` traversal on every item write — conflicts with the "minimal work" performance constraint, and `list.replace()` already exists as the correct API for this case, pinned by `test/list.test.ts:261`) | Each behavior is either inherent to any correct fine-grained reactive graph (read-based edge creation, two-level dirty/check flagging, lazy lifecycle keyed on subscriber count) or an already-decided trade-off with a working escape hatch. The fix is conceptual, not code: a reader who understands the model shouldn't find these surprising. Serves REQUIREMENTS.md goal #2 (predictable mental model) without touching synchronous-path performance (goal #4). |

## Testing Strategy

Expand Down
Loading
Loading