From d81b8e2768df5353be8f4003a7cafb4afdc93bfe Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 15:59:19 +0200 Subject: [PATCH 01/11] fix(effect): contain flush errors and bound effect convergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coordinated fixes to effect scheduling in flush(): 1. Error containment: a throwing effect no longer skips sibling effects. Errors are collected per effect, the queue is fully drained, then a single error is rethrown as-is and multiple errors are wrapped in an AggregateError. FLAG_RUNNING is cleared in runEffect's finally, so a throwing effect recovers instead of causing a spurious CircularDependencyError on its next refresh. 2. Bounded convergence: an effect that writes a signal it depends on re-runs until the graph settles, so its last run always observes the final signal values (previously even a converging clamp effect ended one run stale). flush() drains queuedEffects in passes over snapshots, capped at 1000 passes; a graph that never settles (unconditional self-write, mutual effect writes — the latter previously looped until heap exhaustion) throws the new EffectConvergenceError. propagate() preserves FLAG_RUNNING on effects and flush() skips mid-run effects, removing a re-entrant runEffect hazard during creation-time writes; createEffect converges via the new internal scheduleEffect(). Docs updated: error-classes and non-obvious-behaviors references, debug workflow, codedocs error table, ARCHITECTURE.md flush description and Key Decisions, createEffect JSDoc, CHANGELOG. Co-Authored-By: Claude Fable 5 --- .../skills/cause-effect/workflows/debug.md | 1 + .../skills/shared/references/error-classes.md | 20 ++ .../references/non-obvious-behaviors.md | 31 ++- ARCHITECTURE.md | 5 +- CHANGELOG.md | 12 ++ .../api-reference/errors-and-utils.md | 2 + index.ts | 1 + src/errors.ts | 18 ++ src/graph.ts | 61 +++++- src/nodes/effect.ts | 9 + test/effect.test.ts | 185 ++++++++++++++++++ 11 files changed, 324 insertions(+), 21 deletions(-) diff --git a/.agents/skills/cause-effect/workflows/debug.md b/.agents/skills/cause-effect/workflows/debug.md index f0da68f..876b9f9 100644 --- a/.agents/skills/cause-effect/workflows/debug.md +++ b/.agents/skills/cause-effect/workflows/debug.md @@ -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` | diff --git a/.agents/skills/shared/references/error-classes.md b/.agents/skills/shared/references/error-classes.md index d95cd39..0781869 100644 --- a/.agents/skills/shared/references/error-classes.md +++ b/.agents/skills/shared/references/error-classes.md @@ -18,6 +18,7 @@ import { ReadonlySignalError, RequiredOwnerError, CircularDependencyError, + EffectConvergenceError, PromiseValueError, } from '@zeix/cause-effect' ``` @@ -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` | @@ -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. + +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. + + 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 diff --git a/.agents/skills/shared/references/non-obvious-behaviors.md b/.agents/skills/shared/references/non-obvious-behaviors.md index 84f9629..b02611f 100644 --- a/.agents/skills/shared/references/non-obvious-behaviors.md +++ b/.agents/skills/shared/references/non-obvious-behaviors.md @@ -198,20 +198,31 @@ 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`. - -**`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. + +**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 -// BUG — infinite loop: effect reads and writes the same state +// Safe — converges: 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 }) ``` -Avoid writing to a signal that the same effect reads. If you need derived state, use a -`createMemo` instead. - +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. + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5534f43..810f7e5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d204412..6f79897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Added + +- **`EffectConvergenceError`**: New error class (exported from the package root), thrown when queued effects keep re-triggering each other without settling within 1000 flush passes. Typical triggers: an effect that unconditionally writes a signal it reads (`createEffect(() => count.set(count.get() + 1))`), or two effects that write each other's dependencies. The error surfaces synchronously from the `set()`/`update()`/`batch()`/`createEffect()` call that triggered the runaway; other queued effects still run before it is thrown. + +### Fixed + +- **A throwing effect no longer skips sibling effects in the same flush** (`src/graph.ts`): Previously, an exception from one effect's callback propagated out of `flush()` immediately — all effects queued after it were silently skipped (their DOM updates and subscriptions lost until some arbitrary later flush), and the throwing effect was left stuck with `FLAG_RUNNING` set, so its next `refresh()` threw a spurious `CircularDependencyError`. Now `flush()` catches per-effect errors, drains the entire queue, and rethrows after: a single error is rethrown as-is (error identity preserved for existing `catch` code), multiple errors are wrapped in an `AggregateError`. `runEffect()` clears `FLAG_RUNNING` in its `finally`, so a previously-throwing effect re-runs normally on the next update. +- **Effects that write signals they depend on now converge** (`src/graph.ts`, `src/nodes/effect.ts`): Previously, `runEffect()` unconditionally overwrote the node's flags with `FLAG_CLEAN` after running, clobbering the dirty re-mark set by the effect's own write — so even a converging clamp effect (`if (v > 10) s.set(10)`) ended one run stale, having rendered the pre-clamp value while the signal held the clamped one; two subscribers of the same signal could disagree. Now `flush()` drains `queuedEffects` in passes over snapshots (effects re-queued during a pass run in the next pass), `propagate()` preserves `FLAG_RUNNING` on effects, and `runEffect()` preserves `FLAG_DIRTY`/`FLAG_CHECK` from the effect's own writes — a self-writing effect re-runs until the graph settles and its last run always observes the final signal values. Creation-time self-writes converge through the same path via a new internal `scheduleEffect()`, which also removes a re-entrant `runEffect` hazard (a write during an effect's creation run previously re-entered the still-running effect via the nested flush). +- **Mutual effect writes no longer hang the process** (`src/graph.ts`): Previously, two effects writing each other's dependencies re-queued each other forever inside one `flush()`, growing the queue until heap exhaustion — there was no loop guard. Now the flush-pass cap (1000) converts this into a loud `EffectConvergenceError` while sibling effects still run. + ## 1.3.4 ### Fixed diff --git a/docs/codedocs/api-reference/errors-and-utils.md b/docs/codedocs/api-reference/errors-and-utils.md index 13a7983..ee4fa5b 100644 --- a/docs/codedocs/api-reference/errors-and-utils.md +++ b/docs/codedocs/api-reference/errors-and-utils.md @@ -9,6 +9,7 @@ Import path for every item on this page: `@zeix/cause-effect`. Source files: `sr ```ts class CircularDependencyError extends Error +class EffectConvergenceError extends Error class NullishSignalValueError extends TypeError class UnsetSignalValueError extends Error class InvalidSignalValueError extends TypeError @@ -24,6 +25,7 @@ Typical triggers: | Error | When it appears | |-------|-----------------| | `CircularDependencyError` | A memo, task, or effect re-enters while already running. | +| `EffectConvergenceError` | Effects keep re-triggering each other without settling within 1000 flush passes (e.g. an effect unconditionally writes a signal it reads). | | `NullishSignalValueError` | You try to create or set a signal to `null` or `undefined`. | | `UnsetSignalValueError` | A Sensor or Task is read before it has a value. | | `InvalidSignalValueError` | A factory helper receives an invalid input shape. | diff --git a/index.ts b/index.ts index 1810384..74e5f75 100644 --- a/index.ts +++ b/index.ts @@ -7,6 +7,7 @@ export { CircularDependencyError, DuplicateKeyError, + EffectConvergenceError, type Guard, InvalidCallbackError, InvalidSignalValueError, diff --git a/src/errors.ts b/src/errors.ts index 76c783c..391120e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -29,6 +29,23 @@ class CircularDependencyError extends Error { } } +/** + * Error thrown when queued effects keep re-triggering each other without settling. + */ +class EffectConvergenceError extends Error { + /** + * Constructs a new EffectConvergenceError. + * + * @param passes - The number of flush passes that ran without the graph settling. + */ + constructor(passes: number) { + super( + `[Effect] Effects did not settle after ${passes} flush passes — check for effects that write to signals they depend on`, + ) + this.name = 'EffectConvergenceError' + } +} + /** * Error thrown when a signal value is null or undefined. */ @@ -184,6 +201,7 @@ function validateCallback( export { type Guard, CircularDependencyError, + EffectConvergenceError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, diff --git a/src/graph.ts b/src/graph.ts index 9b1c4c1..64da080 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,4 +1,9 @@ -import { CircularDependencyError, type Guard, PromiseValueError } from './errors' +import { + CircularDependencyError, + EffectConvergenceError, + type Guard, + PromiseValueError, +} from './errors' import { isRecord } from './util' /* === Internal Types === */ @@ -408,7 +413,7 @@ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void { // Enqueue effect for later execution const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK) - node.flags = newFlag + node.flags = (flags & FLAG_RUNNING) | newFlag if (!wasQueued) queuedEffects.push(node as EffectNode) } } @@ -560,9 +565,9 @@ function runEffect(node: EffectNode): void { activeSink = prevContext activeOwner = prevOwner trimSources(node) + // Keep a re-mark from the effect's own writes so it converges + node.flags &= FLAG_DIRTY | FLAG_CHECK } - - node.flags = FLAG_CLEAN } function refresh(node: SinkNode): void { @@ -594,19 +599,56 @@ function refresh(node: SinkNode): void { /* === Batching === */ +const MAX_FLUSH_PASSES = 1000 + function flush(): void { if (flushing) return flushing = true + let errors: unknown[] | undefined + let passes = 0 try { - for (let i = 0; i < queuedEffects.length; i++) { - // biome-ignore lint/style/noNonNullAssertion: index is always within bounds of a populated EffectNode[] - const effect = queuedEffects[i]! - if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) refresh(effect) + while (queuedEffects.length > 0) { + if (++passes > MAX_FLUSH_PASSES) { + queuedEffects.length = 0 + if (!errors) errors = [] + errors.push(new EffectConvergenceError(MAX_FLUSH_PASSES)) + break + } + const batch = queuedEffects.slice() + queuedEffects.length = 0 + for (let i = 0; i < batch.length; i++) { + // biome-ignore lint/style/noNonNullAssertion: index is always within bounds of a populated EffectNode[] + const effect = batch[i]! + // A running effect is converged by its own runner after its fn returns + if (effect.flags & FLAG_RUNNING) continue + if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) { + try { + refresh(effect) + } catch (err) { + if (!errors) errors = [] + errors.push(err) + } + } + } } - queuedEffects.length = 0 } finally { flushing = false } + if (errors) { + if (errors.length === 1) throw errors[0] + throw new AggregateError(errors, 'Multiple effects threw during flush') + } +} + +/** + * Enqueues an effect that is still dirty after running (it wrote to its own + * dependencies) and, outside a batch, flushes until the graph converges. + */ +function scheduleEffect(node: EffectNode): void { + if (node.flags & (FLAG_DIRTY | FLAG_CHECK)) { + queuedEffects.push(node) + if (batchDepth === 0) flush() + } } /** @@ -799,6 +841,7 @@ export { registerCleanup, runCleanup, runEffect, + scheduleEffect, setState, trimSources, TYPE_COLLECTION, diff --git a/src/nodes/effect.ts b/src/nodes/effect.ts index 0150d6b..368811c 100644 --- a/src/nodes/effect.ts +++ b/src/nodes/effect.ts @@ -14,6 +14,7 @@ import { registerCleanup, runCleanup, runEffect, + scheduleEffect, type Signal, trimSources, } from '../graph' @@ -65,9 +66,16 @@ type SingleMatchHandlers = { * Effects run immediately upon creation and re-run when any tracked signal changes. * Effects are executed during the flush phase, after all updates have been batched. * + * An effect that writes to a signal it also depends on re-runs until the graph + * settles, so its last run always observes the final signal values. Effect graphs + * that never settle (e.g. an unconditional self-increment, or two effects writing + * each other's dependencies) throw `EffectConvergenceError` after a bounded number + * of flush passes instead of looping forever. + * * @since 0.1.0 * @param fn - The effect function that can track dependencies and register cleanup callbacks * @returns A cleanup function that can be called to dispose of the effect + * @throws EffectConvergenceError If effects keep re-triggering each other without settling * * @example * ```ts @@ -111,6 +119,7 @@ function createEffect(fn: EffectCallback): Cleanup { if (activeOwner) registerCleanup(activeOwner, dispose) runEffect(node) + scheduleEffect(node) return dispose } diff --git a/test/effect.test.ts b/test/effect.test.ts index a31634f..1759f63 100644 --- a/test/effect.test.ts +++ b/test/effect.test.ts @@ -6,6 +6,7 @@ import { createScope, createState, createTask, + EffectConvergenceError, match, RequiredOwnerError, } from '../index.ts' @@ -344,6 +345,190 @@ describe('createEffect', () => { }) }) + describe('Error Containment', () => { + test('should run sibling effects even when an earlier effect throws', () => { + const source = createState(0) + let goodRuns = 0 + createEffect(() => { + if (source.get() > 0) throw new Error('boom') + }) + createEffect(() => { + source.get() + goodRuns++ + }) + expect(goodRuns).toBe(1) + expect(() => source.set(1)).toThrow('boom') + expect(goodRuns).toBe(2) + }) + + test('should aggregate multiple effect errors and still run non-throwing siblings', () => { + const source = createState(0) + let goodRuns = 0 + createEffect(() => { + if (source.get() > 0) throw new Error('first') + }) + createEffect(() => { + if (source.get() > 0) throw new Error('second') + }) + createEffect(() => { + source.get() + goodRuns++ + }) + let caught: unknown + try { + source.set(1) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(AggregateError) + expect((caught as AggregateError).errors.length).toBe(2) + expect(((caught as AggregateError).errors[0] as Error).message).toBe( + 'first', + ) + expect(((caught as AggregateError).errors[1] as Error).message).toBe( + 'second', + ) + expect(goodRuns).toBe(2) + }) + + test('should re-run a previously throwing effect without CircularDependencyError', () => { + const source = createState(0) + let lastSeen = 0 + createEffect(() => { + const value = source.get() + if (value === 1) throw new Error('boom') + lastSeen = value + }) + expect(() => source.set(1)).toThrow('boom') + + let caught: unknown + try { + source.set(2) + } catch (err) { + caught = err + } + expect(caught).toBeUndefined() + expect(lastSeen).toBe(2) + }) + + test('should contain effect errors thrown during batch', () => { + const source = createState(0) + let goodRuns = 0 + createEffect(() => { + if (source.get() > 0) throw new Error('boom') + }) + createEffect(() => { + source.get() + goodRuns++ + }) + expect(() => { + batch(() => { + source.set(1) + }) + }).toThrow('boom') + expect(goodRuns).toBe(2) + }) + }) + + describe('Convergence', () => { + test('should re-run a clamping effect so it observes the final value', () => { + const source = createState(0) + const seen: number[] = [] + createEffect(() => { + const v = source.get() + seen.push(v) + if (v > 10) source.set(10) + }) + source.set(15) + expect(source.get()).toBe(10) + expect(seen).toEqual([0, 15, 10]) + }) + + test('should converge a clamping effect inside batch', () => { + const source = createState(0) + const seen: number[] = [] + createEffect(() => { + const v = source.get() + seen.push(v) + if (v > 10) source.set(10) + }) + batch(() => { + source.set(20) + }) + expect(source.get()).toBe(10) + expect(seen).toEqual([0, 20, 10]) + }) + + test('should throw EffectConvergenceError for an unconditional self-write via set()', () => { + const source = createState(0) + createEffect(() => { + if (source.get() > 0) source.set(source.get() + 1) + }) + let caught: unknown + try { + source.set(1) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(EffectConvergenceError) + expect((caught as Error).message).toContain('did not settle') + }) + + test('should throw EffectConvergenceError for a runaway effect at creation', () => { + const source = createState(0) + expect(() => + createEffect(() => { + source.set(source.get() + 1) + }), + ).toThrow(EffectConvergenceError) + }) + + test('should throw EffectConvergenceError for mutual effect writes', () => { + const a = createState(0) + const b = createState(0) + expect(() => { + createEffect(() => { + b.set(a.get() + 1) + }) + createEffect(() => { + a.set(b.get() + 1) + }) + }).toThrow(EffectConvergenceError) + }) + + test('should still run healthy siblings when another effect fails to converge', () => { + const source = createState(0) + let siblingRuns = 0 + createEffect(() => { + if (source.get() > 0) source.set(source.get() + 1) + }) + createEffect(() => { + source.get() + siblingRuns++ + }) + expect(siblingRuns).toBe(1) + expect(() => source.set(1)).toThrow(EffectConvergenceError) + expect(siblingRuns).toBeGreaterThan(1) + }) + + test('should recover after a convergence error', () => { + const source = createState(0) + const seen: number[] = [] + createEffect(() => { + seen.push(source.get()) + }) + const runaway = createState(0) + createEffect(() => { + if (runaway.get() > 0) runaway.set(runaway.get() + 1) + }) + expect(() => runaway.set(1)).toThrow(EffectConvergenceError) + + // The queue was cleared; unrelated signals keep working normally + source.set(5) + expect(seen).toEqual([0, 5]) + }) + }) + describe('Input Validation', () => { test('should throw InvalidCallbackError for non-function', () => { // @ts-expect-error - Testing invalid input From 7a64ab8d28f6b8b5ec72bab136745a6523a62aa0 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 15:59:31 +0200 Subject: [PATCH 02/11] docs: add runtime-verified improvement plans Executable plans for four remaining defects found during the code audit: List mutation tracking leaks, missing CI workflow, package exports map, and Store proxy write guard. Co-Authored-By: Claude Fable 5 --- PLAN-ci-workflow.md | 124 +++++++++++++++++++++++++++ PLAN-list-mutation-tracking-leaks.md | 116 +++++++++++++++++++++++++ PLAN-package-exports.md | 101 ++++++++++++++++++++++ PLAN-store-proxy-write-guard.md | 96 +++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 PLAN-ci-workflow.md create mode 100644 PLAN-list-mutation-tracking-leaks.md create mode 100644 PLAN-package-exports.md create mode 100644 PLAN-store-proxy-write-guard.md diff --git a/PLAN-ci-workflow.md b/PLAN-ci-workflow.md new file mode 100644 index 0000000..1086cf3 --- /dev/null +++ b/PLAN-ci-workflow.md @@ -0,0 +1,124 @@ +# PLAN: CI Workflow + Test Gate on Publish + +## Goal + +The repository has **no CI**. The only workflow (`.github/workflows/npm-publish.yml`) publishes to npm on GitHub release **without running a single test** — a release cut from a broken branch ships broken code with provenance attached. Add a CI workflow that runs typecheck, lint, tests, bundle-size regression, and build on every push/PR, and make the publish workflow run the same gate before publishing. + +All commands below were verified to pass locally on `next` (2026-07-10): + +- `bunx tsc --noEmit` → exit 0 +- `bunx biome lint .` → "Checked 49 files… No fixes applied." (exit 0) +- `bun test --path-ignore-patterns='**/regression*'` (what `bun run test` does) → 561 pass +- `bun test test/regression-bundle.test.ts` → 3 pass + +**Important pitfalls already checked:** `bun run lint` is `biome lint --write` (mutating — never use in CI), and `bunx biome ci .` FAILS on this repo (46 formatting errors — the repo is lint-clean but not biome-*format*-clean). CI must use `bunx biome lint .`, not `biome ci` and not `biome check`. + +## Files to touch + +- `.github/workflows/ci.yml` — new file +- `.github/workflows/npm-publish.yml` — add test gate before publish +- `package.json` — add a `check` convenience script (optional, step 3) + +## Implementation steps + +1. **Create `.github/workflows/ci.yml`:** + + ```yaml + name: CI + + on: + push: + branches: [main, next] + pull_request: + branches: [main, next] + + 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 + run: bunx biome lint . + + - 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: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - name: Performance regression (informational — shared runners are noisy) + run: bun test test/regression-performance.test.ts + ``` + + Match the existing workflow's indentation style (4 spaces, as in `npm-publish.yml`). + +2. **Gate the publish workflow.** In `.github/workflows/npm-publish.yml`, insert after the "Install all dependencies" step and **before** the "Build package" step: + + ```yaml + - name: Typecheck + run: bunx tsc --noEmit + + - name: Lint + run: bunx biome lint . + + - name: Run tests + run: bun run test + + - name: Bundle size regression + run: bun test test/regression-bundle.test.ts + ``` + + Keep everything else in that workflow unchanged (the provenance permissions block, version/tag detection, Node setup, `npm publish` step). + +3. **(Optional but recommended)** Add to `package.json` scripts so the gate is one command locally and in CI: + + ```json + "check": "bunx tsc --noEmit && bunx biome lint . && bun run test && bun test test/regression-bundle.test.ts" + ``` + + If added, CI steps 3–6 in `ci.yml` and the publish gate can each be collapsed to `bun run check` — but keep separate named steps in `ci.yml` for readable failure annotations; use the script only in `npm-publish.yml`. + +4. **Verify** by pushing to a branch and opening a draft PR against `next`; confirm both jobs run and the `test` job is green. Confirm the `performance` job is marked neutral/failed without failing the run (because of `continue-on-error`). + +## Edge cases a weaker model would likely miss + +- **`bun run test` already excludes regression tests** (`--path-ignore-patterns=**/regression*` in package.json) — do not run plain `bun test`, which would include the performance regression suite and make CI flaky. +- **Performance regression tests compare against the published stable release** (`@zeix/cause-effect-stable` npm alias) with a 20% margin and 1 ms floor (`test/regression-performance.test.ts`). On shared GitHub runners these timings are noisy — that's why the `performance` job is separate with `continue-on-error: true`. Do NOT put it in the required job, and do NOT delete it either; it's useful signal on large regressions. +- **Bundle regression is deterministic** (builds with `Bun.build` and measures bytes) — it belongs in the required job. +- **`biome ci` / `biome check` include formatting and fail on this repo** (46 format diffs, verified). Only `biome lint` is clean. If someone later formats the repo, CI can be upgraded to `biome ci .` — leave a one-line comment in the workflow noting this. +- **`bun install --frozen-lockfile`** — the publish workflow currently uses plain `bun install`; keep publish as-is (it must tolerate lockfile drift at release time is arguable, but out of scope), use frozen in CI so PRs can't silently change dependencies. +- **`typescript` is a devDependency and a peerDependency** — `bun install` installs it; `bunx tsc` resolves the local 6.x, not a global one. No extra setup needed. +- **The `types/` directory is gitignored?** Check: it is committed (exists in repo). `bunx tsc --noEmit` uses `tsconfig.json`, which *excludes* `types/` and `index.js` — no conflict with stale build artifacts. +- **Branch protection is not configurable from the repo** — after merging, the user must mark the `test` job as a required status check in GitHub settings for `main` and `next`. Note this in the PR description. + +## Acceptance criteria + +1. `ci.yml` exists; on a test PR, the `test` job passes all six steps and the `performance` job runs without being able to fail the overall check. +2. `npm-publish.yml` contains typecheck/lint/test/bundle-regression steps that execute before "Build package". +3. `act`-free validation: `bunx tsc --noEmit && bunx biome lint . && bun run test && bun test test/regression-bundle.test.ts && bun run build` exits 0 locally (this is exactly what the required job runs). +4. YAML is valid: `bunx yaml-lint .github/workflows/ci.yml` or simply GitHub's workflow parser accepting the file on push (no "workflow file issue" annotation). diff --git a/PLAN-list-mutation-tracking-leaks.md b/PLAN-list-mutation-tracking-leaks.md new file mode 100644 index 0000000..fa5e176 --- /dev/null +++ b/PLAN-list-mutation-tracking-leaks.md @@ -0,0 +1,116 @@ +# PLAN: Untrack Internal Reads in List (and Collection) Mutation Methods + +## Goal + +Mutation methods must never create dependency edges to the caller's reactive context. Today, several `List` mutation methods read child item signals **without `untrack()`**, so calling them inside an effect or memo secretly subscribes that effect/memo to every item signal it touched. + +**Confirmed defect (reproduced on `next`, 2026-07-10):** + +```ts +const list = createList([3, 1, 2]) +const trigger = createState(0) +let runs = 0 +createEffect(() => { + trigger.get() + runs++ + if (runs === 1) list.sort() // leaks item-signal edges into this effect +}) +list.byKey('0')?.set(99) +// BUG: runs === 2 — the effect re-ran even though it never *reads* the list. +``` + +The same leak class exists in `list.set()` (via an untracked-missing `buildValue()` call) and `list.splice()` (reads removed items' values tracked). The codebase already establishes the correct pattern in two places — `Store.set()` uses `untrack(buildValue)` with an explaining comment (`src/nodes/store.ts:314–319`), and `List.replace()` wraps its read in `untrack` (`src/nodes/list.ts:507–513`) — but `List.set`, `List.sort`, `List.splice`, and the `update()` methods were never given the same treatment. + +## Files to touch + +- `src/nodes/list.ts` — `set()`, `sort()`, `splice()`, `update()` +- `src/nodes/store.ts` — `update()` (consistency with List; `set()` is already correct) +- `src/nodes/collection.ts` — `onChanges` change branch in `createCollection` +- `test/list.test.ts`, `test/store.test.ts`, `test/collection.test.ts` — new tests +- `.agents/skills/shared/references/non-obvious-behaviors.md` — no entry needed once fixed (this is a bug fix, not a documented behavior); do not add one + +## Implementation steps + +All edits use the already-imported `untrack` from `../graph` (it is imported in all three files). + +1. **`src/nodes/list.ts` — `set()`** (~line 425). Change: + + ```ts + const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value + ``` + + to (mirroring `store.ts` `set()` exactly, including the rationale comment): + + ```ts + // Use cached value if clean, recompute if dirty. untrack prevents + // buildValue's child .get() calls from leaking edges into whatever + // effect is currently active (which would cause over-broad re-runs). + const prev = + node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value + ``` + +2. **`src/nodes/list.ts` — `sort()`** (~line 526–531). The entry-collection loop reads `signals.get(key)?.get()` tracked. Wrap only the reads: + + ```ts + const entries: [string, T][] = [] + untrack(() => { + for (const key of keys) { + const v = signals.get(key)?.get() + if (v !== undefined) entries.push([key, v]) + } + }) + ``` + +3. **`src/nodes/list.ts` — `splice()`** (~line 568–578). In the "Collect items to delete" loop, change `remove[key] = signal.get()` so the read is untracked. Simplest: wrap the whole collect loop in `untrack(() => { ... })`, same shape as step 2. + +4. **`src/nodes/list.ts` — `update()`** (~line 443). Change: + + ```ts + update(fn: (prev: T[]) => T[]) { + list.set(fn(list.get())) + }, + ``` + + to: + + ```ts + update(fn: (prev: T[]) => T[]) { + list.set(fn(untrack(() => list.get()))) + }, + ``` + + Rationale: `State.update()` reads `node.value` directly and creates no edge (`src/nodes/state.ts:105–110`); `List.update`/`Store.update` calling the tracked `get()` is an inconsistency — a mutation API must not subscribe its caller. + +5. **`src/nodes/store.ts` — `update()`** (~line 329): same change, `store.set(fn(untrack(() => store.get())))`. + +6. **`src/nodes/collection.ts` — `createCollection`'s `onChanges`, change branch** (~line 451–456): `itemToKey.delete(signal.get())` reads the item signal tracked. `onChanges` is normally called from the external `watched` callback (no active sink), but nothing stops a consumer from calling `applyChanges` inside an effect. Change to: + + ```ts + itemToKey.delete(untrack(() => signal.get())) + ``` + +7. **Tests.** Add to the respective test files (style: `bun:test`, existing helpers): + - For each of `set`, `sort`, `splice`, `update` on List: create a list, an unrelated `trigger` state, and an effect that reads only `trigger` and performs the mutation on its first run. Then mutate an item via `list.byKey(key)!.set(...)` and assert the effect's run count did **not** increase. (Use the auto-increment keys `'0'`, `'1'`, … — the default `keyConfig`.) + - For `List.set` specifically, reproduce the transient variant: effect does `list.add(4)` (marks the node dirty) then `list.set([...])` in its first run; assert run count is exactly 1 after flush (before the fix it becomes 2). + - `Store.update` inside an effect that reads only `trigger`: later `store.byKey('name').set(...)` must not re-run the effect. + - Behavior preservation: `sort`/`splice`/`set` called *outside* any effect still propagate to subscribers exactly as before (there are existing tests; just make sure they pass). + +8. Run `bun test`, `bun run regression`, `bunx biome lint .`, `bunx tsc --noEmit`. + +## Edge cases a weaker model would likely miss + +- **`untrack` only suppresses `activeSink` linking; it does not suppress the composite node's own bookkeeping.** Wrapping `buildValue()` in `untrack` is safe: the child→list-node edges (ADR-0014's value-rebuild path) are established by `refresh()`/`recomputeMemo()` during `get()`, not by these mutation-path reads. Do not "fix" this by skipping `buildValue` — the dirty-prev computation is needed for correct diffing. +- **Do NOT untrack the public read APIs** (`get()`, `at()`, `byKey()`, `keys()`, `length`, iterator). ADR-0015 made those deliberately tracking. Only *mutation-internal* reads are in scope. +- **`replace()` is already correct** (`untrack` at `src/nodes/list.ts:507`) — leave it alone; it's the precedent, and there's a batch-wrapping subtlety there covered by ADR-0015. +- **The leak has two observable modes**: (a) persistent — leaked edges survive until the effect's next run (`sort` case above), and (b) transient — the mutation itself propagates through the just-leaked edge and re-runs the effect once, after which `trimSources` on that re-run removes the edges (the `set` case). Tests must cover both; asserting only mode (a) for `set()` would pass even without the fix. +- **`update()` semantics change is intentional but observable**: an effect that calls `list.update(...)` on its first run today becomes subscribed to the whole list; after the fix it is not. Grep tests for `\.update(` usages inside `createEffect` before changing, and confirm none rely on the tracking (as of today, none do — verify with `grep -n "update(" test/*.test.ts`). +- **`sort()`'s `compareFn` runs on item *values* already read** — do not move the user's `compareFn` inside `untrack`'s callback-read loop in a way that changes when it executes; only the `signals.get(key)?.get()` reads need untracking. (Wrapping the collect loop as shown is fine; the sort itself stays outside.) +- **In `splice()`, the returned array `Object.values(remove)`** is built from those same reads — untracking does not change the return value. +- **Bundle size**: `untrack` closures add a few bytes each; regression limits have headroom, but run `bun run regression` to confirm. + +## Acceptance criteria + +1. The reproduction at the top yields `runs === 1` after `byKey('0').set(99)` (effect not re-run). +2. New tests for `set` (both modes), `sort`, `splice`, `update` (List + Store) pass. +3. All 561 pre-existing tests pass unchanged (`bun test`), `bun run regression` passes, `bunx tsc --noEmit` exits 0, `bunx biome lint .` clean. +4. `grep -n "signal.get()\|?.get()" src/nodes/list.ts src/nodes/collection.ts` shows no remaining tracked child-signal read inside a mutation method (each is inside `untrack(...)` or in a read API where tracking is intended per ADR-0015). diff --git a/PLAN-package-exports.md b/PLAN-package-exports.md new file mode 100644 index 0000000..c9d8337 --- /dev/null +++ b/PLAN-package-exports.md @@ -0,0 +1,101 @@ +# PLAN: Correct npm Packaging (exports map, tree-shaking, peer-dep metadata) + +## Goal + +Fix distribution metadata so the package resolves correctly and tree-shakes in all supported toolchains. Current problems in `package.json`: + +1. **No `exports` map** — no explicit entry conditions, no `types` condition, and deep imports into package internals are uncontrolled. +2. **`"module": "index.ts"` points at TypeScript source.** Bundlers that honor the `module` field (webpack, older Rollup configs) will try to parse raw TS from `node_modules`, which most consumer configs exclude from transpilation → build errors. `module` must point to JS. +3. **No `"sideEffects": false`** — REQUIREMENTS.md explicitly demands "the library must remain tree-shakable", but without this flag webpack retains the whole bundle. (Verified: the codebase has no module-level side effects — only const declarations, class definitions, and pure helpers; `Object.getPrototypeOf(async () => {})` in `src/util.ts` and the module-scope `WeakSet`/arrays in `graph.ts`/`slot.ts` are allocation-only, side-effect-free.) +4. **`main` is a *minified* bundle** (`index.js`, built with `bun build --minify`). Publishing minified code hurts consumer debugging and bug reports; consumers' bundlers minify anyway. The unminified `index.dev.js` exists but nothing points to it. +5. **`typescript` is a hard `peerDependency`** — JS-only consumers get an unresolvable-peer warning on every install. It should be optional. +6. **Packaging is controlled by a fragile `.npmignore`** with negation patterns; an explicit `files` allowlist is safer. + +## Files to touch + +- `package.json` +- `.npmignore` — delete (replaced by `files`) +- `test/regression-bundle.test.ts` — no change needed (it builds from `index.ts` on the fly, not from the shipped `index.js`); verify only. +- `REQUIREMENTS.md` — no change needed unless acceptance step 5 finds issues. + +## Implementation steps + +1. **Swap the roles of the two build outputs** so the published entry is the *unminified* ESM bundle. In `package.json` `scripts.build`, change: + + ``` + bunx tsc --project tsconfig.build.json && bun build index.ts --outdir ./ --minify && bun build index.ts --outfile index.dev.js + ``` + + to: + + ``` + bunx tsc --project tsconfig.build.json && bun build index.ts --outfile index.js + ``` + + and delete `index.dev.js` from the repo (`git rm index.dev.js`). The minified artifact serves no consumer (bundlers minify; the size budget is enforced by `test/regression-bundle.test.ts`, which builds its own minified bundle from source). If the user prefers to keep a minified file for CDN-style usage, instead keep both files and point a `"production"` exports condition at the minified one — but the default/simple path is one unminified `index.js`. + +2. **Rewrite the packaging fields in `package.json`:** + + ```json + "main": "index.js", + "types": "types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "bun": "./index.ts", + "default": "./index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "index.js", + "index.ts", + "src", + "types", + "SECURITY.md" + ], + ``` + + - **Delete the `"module": "index.ts"` field entirely.** With an `exports` map, `module` is ignored by modern resolvers; leaving it pointing at TS keeps the webpack hazard alive for tools that still read it. Do not repoint it at `index.js` — just remove it. + - The `"bun"` condition lets Bun consumers (Le Truc, per REQUIREMENTS.md) resolve the TS source directly — this is why `index.ts` and `src/` stay in `files`. + - `README.md` and `LICENSE` are always included by npm automatically; they do not need to be in `files`. + - Key order inside the `"."` conditions matters: `"types"` must come first, `"default"` last. + +3. **Make the TypeScript peer dependency optional:** + + ```json + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + ``` + +4. **Delete `.npmignore`.** With a `files` allowlist present, npm ignores `.npmignore` for inclusion logic anyway; keeping both invites drift. + +5. **Validate the package shape:** + - `bun run build` (regenerates `types/` and `index.js`). + - `npm pack --dry-run` — inspect the file list: exactly `index.js`, `index.ts`, `src/**`, `types/**`, `package.json`, `README.md`, `LICENSE`, `SECURITY.md`. No `test/`, no `adr/`, no `.agents/`, no `bench/`. + - `bunx publint` — must report no errors (warnings about `bun` condition ordering are acceptable if any). + - `bunx @arethetypeswrong/cli --pack .` — must show ESM resolution finding `types/index.d.ts` for the `import` condition; "no types for require" is expected and fine (the library is ESM-only by design; see the comment in `src/graph.ts:187–191` about live bindings — do NOT add a CJS build to satisfy the tool). + - Smoke test in a scratch dir: `npm pack`, then in `/tmp/pkgtest` create `package.json` (`"type": "module"`), `npm i `, and run `node -e "import('@zeix/cause-effect').then(m => console.log(typeof m.createState))"` → prints `function`. + +## Edge cases a weaker model would likely miss + +- **Do NOT add a CommonJS build.** `src/graph.ts` documents that `activeSink`/`activeOwner`/`batchDepth` are exported mutable `let` bindings relying on ESM live-binding semantics; a CJS transform snapshots them and silently breaks dependency tracking. ESM-only is a load-bearing design decision (REQUIREMENTS.md), not an oversight. +- **The `exports` map breaks deep imports** like `@zeix/cause-effect/src/nodes/list.ts`. Nothing in the repo or docs advertises deep imports, and the barrel re-exports everything, so this is acceptable — but mention it in the changelog entry (via the changelog-keeper skill) as a potentially breaking packaging change; consider a minor (not patch) version bump. +- **The `"bun"` condition must appear before `"default"`** or Bun will never reach it. Conditions are matched in object order. +- **`sideEffects: false` is a promise, not a flag** — if anyone later adds a module-level side effect (e.g., a global registration, a polyfill import), tree-shaking will silently drop it in consumer builds. Add a short comment in ARCHITECTURE.md or a note in the cause-effect-dev skill reference if the team wants a guardrail; at minimum, the core-tree-shaking regression test (`bundleCoreGzipped`) partially covers this. +- **`types/index.d.ts` is generated by `tsc --project tsconfig.build.json`** and the publish workflow runs `bun run build` before `npm publish` — so `files: ["types"]` is safe in CI. But `npm pack` from a fresh clone without building would produce a tarball missing fresh types; add `"prepack": "bun run build"`? **No** — npm runs `prepack` with npm's own node, and the build needs Bun. The publish workflow already builds; leave it, but note in the plan-executor's PR description that `npm pack` requires a prior `bun run build`. +- **`index.dev.js` removal**: grep the repo for references first (`grep -rn "index.dev" --exclude-dir=node_modules .`) — as of today it appears only in `package.json`'s build script and `.npmignore`; both are being edited by this plan. If it also appears in docs, update them. +- **The regression bundle test is independent of these changes** (it builds from `index.ts` with `Bun.build`), so size limits cannot regress from this plan — but run `bun run regression` anyway as a guard. + +## Acceptance criteria + +1. `npm pack --dry-run` lists exactly the intended files (see step 5) — no test/dev/tooling files. +2. `bunx publint` passes with no errors. +3. `bunx @arethetypeswrong/cli --pack .` shows correct ESM + types resolution (no ❌ for the `import` entrypoint; `require` failures acceptable/ESM-only). +4. Node smoke test (step 5, last bullet) prints `function`. +5. `package.json` contains `exports` (with `types` first), `sideEffects: false`, `files`, `peerDependenciesMeta.typescript.optional: true`, and no `module` field; `.npmignore` is deleted. +6. `bun test` and `bun run regression` still pass; `bun run build` produces `index.js` (unminified) and `types/`. diff --git a/PLAN-store-proxy-write-guard.md b/PLAN-store-proxy-write-guard.md new file mode 100644 index 0000000..d24e45f --- /dev/null +++ b/PLAN-store-proxy-write-guard.md @@ -0,0 +1,96 @@ +# PLAN: Store Proxy Write Guard (reject direct assignment / delete) + +## Goal + +Direct property assignment on a `Store` proxy currently **corrupts the store silently**. + +**Confirmed defect (reproduced on `next`, 2026-07-10):** + +```ts +const store = createStore({ name: 'Alice' }) +;(store as any).name = 'Bob' // no error! +store.name // 'Bob' — raw string, the State signal is now shadowed +store.get() // { name: 'Alice' } — the reactive value is unchanged +``` + +The proxy in `createStore` (`src/nodes/store.ts:354–381`) defines `get`, `has`, `ownKeys`, and `getOwnPropertyDescriptor` traps but **no `set`, `deleteProperty`, or `defineProperty` traps**. The default `set` behavior writes the raw value onto the `BaseStore` target object; from then on `prop in target` is true, so the `get` trap returns the raw value instead of the child signal, `has` reports it, and the store's reactive state diverges from what property access shows. `delete store.name` similarly bypasses `remove()` semantics (it deletes nothing reactive but returns `true`). + +Fix: make misuse loud. Assignment and deletion through the proxy throw a descriptive error pointing at the correct API (`store.key.set(v)` / `store.set({...})` / `store.add(k, v)` / `store.remove(k)`). This matches the library's existing philosophy of throwing typed, descriptive errors on misuse (`ReadonlySignalError`, `DuplicateKeyError`, `InvalidSignalValueError`). + +Deliberately **not** routing `store.name = 'Bob'` to `signals.get('name').set('Bob')`: that would be a new write API, needs shape-category replacement logic (primitive→array transitions, see `applyChanges` at `src/nodes/store.ts:226`), changes the public contract, and per REQUIREMENTS.md new surface is added reluctantly. Throwing is the safe, non-committal fix; routing can be a future ADR. + +## Files to touch + +- `src/errors.ts` — new error class +- `src/nodes/store.ts` — add proxy traps +- `index.ts` — export the new error class +- `test/store.test.ts` — new tests +- `.agents/skills/shared/references/non-obvious-behaviors.md` — new entry for method-name shadowing (step 5) +- `adr/` — new ADR via the adr-keeper skill (step 6; optional if the user prefers no ADR) + +## Implementation steps + +1. **Add an error class** in `src/errors.ts`, following the existing style (see `ReadonlySignalError` at line 94): + + ```ts + /** + * Error thrown when a Store property is assigned or deleted directly via the proxy. + */ + class InvalidStoreMutationError extends TypeError { + constructor(prop: string, hint: string) { + super(`[Store] Cannot ${hint} property "${prop}" directly`) + this.name = 'InvalidStoreMutationError' + } + } + ``` + + Adjust the message to include guidance, e.g. for set: `` `[Store] Cannot assign to property "${prop}" directly — use store.${prop}.set(value), store.set(next), or store.add(key, value)` ``. Keep one class with a parameterized message rather than two classes. Export it from `src/errors.ts` and re-export from `index.ts` (alphabetical position within the existing error export block). + +2. **Add traps to the Proxy in `createStore`** (`src/nodes/store.ts`, inside `new Proxy(store, { ... })`): + + ```ts + set(_target, prop) { + throw new InvalidStoreMutationError(String(prop), 'assign to') + }, + deleteProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), 'delete') + }, + defineProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), 'define') + }, + ``` + + (Exact message wording per step 1; the three hints should produce messages that name the right alternative: `set()` family for assign/define, `store.remove(key)` for delete.) + +3. **Import** `InvalidStoreMutationError` in `store.ts` (extend the existing `import { DuplicateKeyError, validateSignalValue } from '../errors'` line). + +4. **Tests** in `test/store.test.ts`: + - `store.name = 'Bob'` throws `InvalidStoreMutationError`; afterwards `store.name` is still the `State` signal (`isState(store.name)` true) and `store.get()` unchanged. + - `delete (store as any).name` throws; `store.get()` unchanged; `store.remove('name')` still works. + - `Object.assign(store, { name: 'Bob' })` throws (goes through the `set` trap). + - `Object.defineProperty(store, 'x', { value: 1 })` throws. + - Regression: reads through the proxy (`store.name.get()`), `keys()`, iteration, spread via `ownKeys` still work (existing tests cover most of this; run them). + +5. **Document the method-name shadowing footgun** discovered while exploring: a store created with a data key named like a base method (`get`, `set`, `keys`, `update`, `add`, `remove`, `byKey`) is unreachable via proxy access — `store.get` returns the method (because the `get` trap checks `prop in target` first) — and only reachable via `store.byKey('get')`. This is inherent to the proxy design and NOT changed by this plan. Add a short entry to `.agents/skills/shared/references/non-obvious-behaviors.md` (follow the existing XML-tag entry format, e.g. ``) showing the `byKey` escape hatch, and a one-sentence note in the `createStore` JSDoc in `src/nodes/store.ts`. + +6. **Record the decision.** Invoke the adr-keeper skill to add an ADR ("Store proxy rejects direct writes") covering: the silent-divergence defect, the throw-vs-route alternatives, and why routing was deferred. If executing this plan in a context without the skill, create `adr/0017-store-proxy-rejects-direct-writes.md` following the structure of `adr/0015-*.md` (Status/Context/Decision/Alternatives Considered/Consequences) and add it to `.agents/skills/adr-keeper/references/adr-index.md`. + +7. Run `bun test`, `bun run regression`, `bunx biome lint .`, `bunx tsc --noEmit`. + +## Edge cases a weaker model would likely miss + +- **Strict-mode proxies require `set` to return `true` or throw.** Returning `false` would make consumers see a generic `TypeError: 'set' on proxy: trap returned falsish` — always throw the descriptive error instead; never `return false`. +- **The traps must throw for symbol properties too** (`store[Symbol.for('x')] = 1`). `String(prop)` handles symbols safely (`Symbol(x)` stringifies via `String()`, not via implicit coercion which would throw). Do not special-case symbols to silently succeed — a symbol write would hit the same shadowing bug via the default behavior. Note: the `get` trap intentionally returns `undefined` for unknown symbols; that stays. +- **`Reflect.set(store, k, v)` and `Object.assign(store, ...)` route through the same `set` trap** — no separate handling needed, but the `Object.assign` test matters because it's the realistic accident (merging "plain object" patterns onto a store). +- **Do not add the traps to the `BaseStore` target itself or freeze the target.** The proxy is the only public handle; internal code never assigns properties onto `store` after construction (verified: `createStore` builds the full object literal once). Freezing would break nothing today but adds risk with zero benefit. +- **This is technically a behavior break**: code that previously "worked" by assigning (and silently corrupting) will now throw. That is the point — but it should be listed under a **minor** version bump with a changelog entry (changelog-keeper skill), not a patch. +- **TypeScript already flags these writes** for typed stores (`Store` properties are typed as signals, so `store.name = 'Bob'` is a type error when `T['name']` is `string`) — the runtime guard matters for `any`-typed access, JS consumers, and `Object.assign`. Tests need `as any` casts or `@ts-expect-error` — prefer `@ts-expect-error` with a reason comment, matching repo test style. +- **`exactOptionalPropertyTypes` and `noUncheckedIndexedAccess` are on** — when writing the error class and traps, `prop` is `string | symbol`; handle the union explicitly, don't assume `string`. +- **Bundle size**: one small class + three traps ≈ 200 B minified; limits have headroom, verify with `bun run regression`. + +## Acceptance criteria + +1. All four new throwing tests pass; the silent-corruption reproduction now throws `InvalidStoreMutationError` and leaves `store.get()` and `isState(store.name)` intact. +2. `InvalidStoreMutationError` is exported from the package barrel (`import { InvalidStoreMutationError } from '@zeix/cause-effect'` type-checks). +3. All pre-existing tests pass; `bun run regression`, `bunx tsc --noEmit`, `bunx biome lint .` all clean. +4. Non-obvious-behaviors reference has the new shadowing entry; ADR exists and is indexed. From 4af21d3d94327b7ac018bbe218de88e255521168 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 16:32:07 +0200 Subject: [PATCH 03/11] fix(list): untrack internal reads in mutation methods List.set, List.sort, List.splice, List.update, Store.update, and createCollection's onChanges change branch read child item signals without untrack(). Calling them inside an effect leaked dependency edges into the caller, causing over-broad re-runs (persistent) or a spurious one-time re-run during setup (transient). All now wrap their internal reads in untrack(), matching the existing pattern in Store.set and List.replace. --- CHANGELOG.md | 1 + PLAN-list-mutation-tracking-leaks.md | 116 --------------------------- src/nodes/collection.ts | 6 +- src/nodes/list.ts | 41 ++++++---- src/nodes/store.ts | 2 +- test/collection.test.ts | 44 ++++++++++ test/list.test.ts | 93 +++++++++++++++++++++ test/store.test.ts | 25 ++++++ 8 files changed, 193 insertions(+), 135 deletions(-) delete mode 100644 PLAN-list-mutation-tracking-leaks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f79897..702d53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **A throwing effect no longer skips sibling effects in the same flush** (`src/graph.ts`): Previously, an exception from one effect's callback propagated out of `flush()` immediately — all effects queued after it were silently skipped (their DOM updates and subscriptions lost until some arbitrary later flush), and the throwing effect was left stuck with `FLAG_RUNNING` set, so its next `refresh()` threw a spurious `CircularDependencyError`. Now `flush()` catches per-effect errors, drains the entire queue, and rethrows after: a single error is rethrown as-is (error identity preserved for existing `catch` code), multiple errors are wrapped in an `AggregateError`. `runEffect()` clears `FLAG_RUNNING` in its `finally`, so a previously-throwing effect re-runs normally on the next update. - **Effects that write signals they depend on now converge** (`src/graph.ts`, `src/nodes/effect.ts`): Previously, `runEffect()` unconditionally overwrote the node's flags with `FLAG_CLEAN` after running, clobbering the dirty re-mark set by the effect's own write — so even a converging clamp effect (`if (v > 10) s.set(10)`) ended one run stale, having rendered the pre-clamp value while the signal held the clamped one; two subscribers of the same signal could disagree. Now `flush()` drains `queuedEffects` in passes over snapshots (effects re-queued during a pass run in the next pass), `propagate()` preserves `FLAG_RUNNING` on effects, and `runEffect()` preserves `FLAG_DIRTY`/`FLAG_CHECK` from the effect's own writes — a self-writing effect re-runs until the graph settles and its last run always observes the final signal values. Creation-time self-writes converge through the same path via a new internal `scheduleEffect()`, which also removes a re-entrant `runEffect` hazard (a write during an effect's creation run previously re-entered the still-running effect via the nested flush). - **Mutual effect writes no longer hang the process** (`src/graph.ts`): Previously, two effects writing each other's dependencies re-queued each other forever inside one `flush()`, growing the queue until heap exhaustion — there was no loop guard. Now the flush-pass cap (1000) converts this into a loud `EffectConvergenceError` while sibling effects still run. +- **`List` and `Store` mutation methods leaked dependency edges into the caller's effect** (`src/nodes/list.ts`, `src/nodes/store.ts`, `src/nodes/collection.ts`): `List.set()`, `List.sort()`, `List.splice()`, `List.update()`, `Store.update()`, and `createCollection`'s `onChanges` change branch read child item signals without `untrack()`. Calling any of them inside an effect silently subscribed that effect to every item signal touched — causing over-broad re-runs on unrelated item mutations (persistent leak) or a spurious one-time re-run during setup (transient leak, removed by `trimSources` on the next run). `Store.set()` and `List.replace()` already had the correct `untrack` pattern; these methods now match it. The public read APIs (`get()`, `at()`, `byKey()`, `keys()`, `length`, iterator) remain deliberately tracking per [ADR-0015](adr/0015-composite-lookup-methods-track-structural-changes.md). ## 1.3.4 diff --git a/PLAN-list-mutation-tracking-leaks.md b/PLAN-list-mutation-tracking-leaks.md deleted file mode 100644 index fa5e176..0000000 --- a/PLAN-list-mutation-tracking-leaks.md +++ /dev/null @@ -1,116 +0,0 @@ -# PLAN: Untrack Internal Reads in List (and Collection) Mutation Methods - -## Goal - -Mutation methods must never create dependency edges to the caller's reactive context. Today, several `List` mutation methods read child item signals **without `untrack()`**, so calling them inside an effect or memo secretly subscribes that effect/memo to every item signal it touched. - -**Confirmed defect (reproduced on `next`, 2026-07-10):** - -```ts -const list = createList([3, 1, 2]) -const trigger = createState(0) -let runs = 0 -createEffect(() => { - trigger.get() - runs++ - if (runs === 1) list.sort() // leaks item-signal edges into this effect -}) -list.byKey('0')?.set(99) -// BUG: runs === 2 — the effect re-ran even though it never *reads* the list. -``` - -The same leak class exists in `list.set()` (via an untracked-missing `buildValue()` call) and `list.splice()` (reads removed items' values tracked). The codebase already establishes the correct pattern in two places — `Store.set()` uses `untrack(buildValue)` with an explaining comment (`src/nodes/store.ts:314–319`), and `List.replace()` wraps its read in `untrack` (`src/nodes/list.ts:507–513`) — but `List.set`, `List.sort`, `List.splice`, and the `update()` methods were never given the same treatment. - -## Files to touch - -- `src/nodes/list.ts` — `set()`, `sort()`, `splice()`, `update()` -- `src/nodes/store.ts` — `update()` (consistency with List; `set()` is already correct) -- `src/nodes/collection.ts` — `onChanges` change branch in `createCollection` -- `test/list.test.ts`, `test/store.test.ts`, `test/collection.test.ts` — new tests -- `.agents/skills/shared/references/non-obvious-behaviors.md` — no entry needed once fixed (this is a bug fix, not a documented behavior); do not add one - -## Implementation steps - -All edits use the already-imported `untrack` from `../graph` (it is imported in all three files). - -1. **`src/nodes/list.ts` — `set()`** (~line 425). Change: - - ```ts - const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value - ``` - - to (mirroring `store.ts` `set()` exactly, including the rationale comment): - - ```ts - // Use cached value if clean, recompute if dirty. untrack prevents - // buildValue's child .get() calls from leaking edges into whatever - // effect is currently active (which would cause over-broad re-runs). - const prev = - node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value - ``` - -2. **`src/nodes/list.ts` — `sort()`** (~line 526–531). The entry-collection loop reads `signals.get(key)?.get()` tracked. Wrap only the reads: - - ```ts - const entries: [string, T][] = [] - untrack(() => { - for (const key of keys) { - const v = signals.get(key)?.get() - if (v !== undefined) entries.push([key, v]) - } - }) - ``` - -3. **`src/nodes/list.ts` — `splice()`** (~line 568–578). In the "Collect items to delete" loop, change `remove[key] = signal.get()` so the read is untracked. Simplest: wrap the whole collect loop in `untrack(() => { ... })`, same shape as step 2. - -4. **`src/nodes/list.ts` — `update()`** (~line 443). Change: - - ```ts - update(fn: (prev: T[]) => T[]) { - list.set(fn(list.get())) - }, - ``` - - to: - - ```ts - update(fn: (prev: T[]) => T[]) { - list.set(fn(untrack(() => list.get()))) - }, - ``` - - Rationale: `State.update()` reads `node.value` directly and creates no edge (`src/nodes/state.ts:105–110`); `List.update`/`Store.update` calling the tracked `get()` is an inconsistency — a mutation API must not subscribe its caller. - -5. **`src/nodes/store.ts` — `update()`** (~line 329): same change, `store.set(fn(untrack(() => store.get())))`. - -6. **`src/nodes/collection.ts` — `createCollection`'s `onChanges`, change branch** (~line 451–456): `itemToKey.delete(signal.get())` reads the item signal tracked. `onChanges` is normally called from the external `watched` callback (no active sink), but nothing stops a consumer from calling `applyChanges` inside an effect. Change to: - - ```ts - itemToKey.delete(untrack(() => signal.get())) - ``` - -7. **Tests.** Add to the respective test files (style: `bun:test`, existing helpers): - - For each of `set`, `sort`, `splice`, `update` on List: create a list, an unrelated `trigger` state, and an effect that reads only `trigger` and performs the mutation on its first run. Then mutate an item via `list.byKey(key)!.set(...)` and assert the effect's run count did **not** increase. (Use the auto-increment keys `'0'`, `'1'`, … — the default `keyConfig`.) - - For `List.set` specifically, reproduce the transient variant: effect does `list.add(4)` (marks the node dirty) then `list.set([...])` in its first run; assert run count is exactly 1 after flush (before the fix it becomes 2). - - `Store.update` inside an effect that reads only `trigger`: later `store.byKey('name').set(...)` must not re-run the effect. - - Behavior preservation: `sort`/`splice`/`set` called *outside* any effect still propagate to subscribers exactly as before (there are existing tests; just make sure they pass). - -8. Run `bun test`, `bun run regression`, `bunx biome lint .`, `bunx tsc --noEmit`. - -## Edge cases a weaker model would likely miss - -- **`untrack` only suppresses `activeSink` linking; it does not suppress the composite node's own bookkeeping.** Wrapping `buildValue()` in `untrack` is safe: the child→list-node edges (ADR-0014's value-rebuild path) are established by `refresh()`/`recomputeMemo()` during `get()`, not by these mutation-path reads. Do not "fix" this by skipping `buildValue` — the dirty-prev computation is needed for correct diffing. -- **Do NOT untrack the public read APIs** (`get()`, `at()`, `byKey()`, `keys()`, `length`, iterator). ADR-0015 made those deliberately tracking. Only *mutation-internal* reads are in scope. -- **`replace()` is already correct** (`untrack` at `src/nodes/list.ts:507`) — leave it alone; it's the precedent, and there's a batch-wrapping subtlety there covered by ADR-0015. -- **The leak has two observable modes**: (a) persistent — leaked edges survive until the effect's next run (`sort` case above), and (b) transient — the mutation itself propagates through the just-leaked edge and re-runs the effect once, after which `trimSources` on that re-run removes the edges (the `set` case). Tests must cover both; asserting only mode (a) for `set()` would pass even without the fix. -- **`update()` semantics change is intentional but observable**: an effect that calls `list.update(...)` on its first run today becomes subscribed to the whole list; after the fix it is not. Grep tests for `\.update(` usages inside `createEffect` before changing, and confirm none rely on the tracking (as of today, none do — verify with `grep -n "update(" test/*.test.ts`). -- **`sort()`'s `compareFn` runs on item *values* already read** — do not move the user's `compareFn` inside `untrack`'s callback-read loop in a way that changes when it executes; only the `signals.get(key)?.get()` reads need untracking. (Wrapping the collect loop as shown is fine; the sort itself stays outside.) -- **In `splice()`, the returned array `Object.values(remove)`** is built from those same reads — untracking does not change the return value. -- **Bundle size**: `untrack` closures add a few bytes each; regression limits have headroom, but run `bun run regression` to confirm. - -## Acceptance criteria - -1. The reproduction at the top yields `runs === 1` after `byKey('0').set(99)` (effect not re-run). -2. New tests for `set` (both modes), `sort`, `splice`, `update` (List + Store) pass. -3. All 561 pre-existing tests pass unchanged (`bun test`), `bun run regression` passes, `bunx tsc --noEmit` exits 0, `bunx biome lint .` clean. -4. `grep -n "signal.get()\|?.get()" src/nodes/list.ts src/nodes/collection.ts` shows no remaining tracked child-signal read inside a mutation method (each is inside `untrack(...)` or in a read API where tracking is intended per ADR-0015). diff --git a/src/nodes/collection.ts b/src/nodes/collection.ts index 5b2b9b2..c364b26 100644 --- a/src/nodes/collection.ts +++ b/src/nodes/collection.ts @@ -449,8 +449,10 @@ function createCollection = Signal>( if (!key) continue const signal = signals.get(key) if (signal && isState(signal)) { - // Update reverse map: remove old reference, add new - itemToKey.delete(signal.get()) + // Update reverse map: remove old reference, add new. + // untrack prevents the read from leaking an edge into + // the caller's effect when applyChanges is called inside one. + itemToKey.delete(untrack(() => signal.get())) signal.set(item) itemToKey.set(item, key) } diff --git a/src/nodes/list.ts b/src/nodes/list.ts index 808cfdc..08a0ed4 100644 --- a/src/nodes/list.ts +++ b/src/nodes/list.ts @@ -422,7 +422,11 @@ function createList< }, set(next: T[]) { - const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value + // Use cached value if clean, recompute if dirty. untrack prevents + // buildValue's child .get() calls from leaking edges into whatever + // effect is currently active (which would cause over-broad re-runs). + const prev = + node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value const changes = diffArrays( prev, next, @@ -441,7 +445,7 @@ function createList< }, update(fn: (prev: T[]) => T[]) { - list.set(fn(list.get())) + list.set(fn(untrack(() => list.get()))) }, at(index: number) { @@ -525,10 +529,12 @@ function createList< sort(compareFn?: (a: T, b: T) => number) { const entries: [string, T][] = [] - for (const key of keys) { - const v = signals.get(key)?.get() - if (v !== undefined) entries.push([key, v]) - } + untrack(() => { + for (const key of keys) { + const v = signals.get(key)?.get() + if (v !== undefined) entries.push([key, v]) + } + }) entries.sort( isFunction(compareFn) ? (a, b) => compareFn(a[1], b[1]) @@ -564,18 +570,21 @@ function createList< const remove = {} as Record let hasRemove = false - // Collect items to delete - for (let i = 0; i < actualDeleteCount; i++) { - const index = actualStart + i - const key = keys[index] - if (key) { - const signal = signals.get(key) - if (signal) { - remove[key] = signal.get() - hasRemove = true + // Collect items to delete — untrack the reads so the caller's + // effect does not gain edges to the deleted item signals. + untrack(() => { + for (let i = 0; i < actualDeleteCount; i++) { + const index = actualStart + i + const key = keys[index] + if (key) { + const signal = signals.get(key) + if (signal) { + remove[key] = signal.get() + hasRemove = true + } } } - } + }) // Build new key order const newOrder = keys.slice(0, actualStart) diff --git a/src/nodes/store.ts b/src/nodes/store.ts index 3df1a2b..d68528e 100644 --- a/src/nodes/store.ts +++ b/src/nodes/store.ts @@ -327,7 +327,7 @@ function createStore( }, update(fn: (prev: T) => T) { - store.set(fn(store.get())) + store.set(fn(untrack(() => store.get()))) }, add(key: K, value: T[K]) { diff --git a/test/collection.test.ts b/test/collection.test.ts index 1084775..440ad67 100644 --- a/test/collection.test.ts +++ b/test/collection.test.ts @@ -614,6 +614,50 @@ describe('Collection', () => { }) }) + describe('onChanges change-branch tracking leak', () => { + // The change branch of onChanges reads signal.get() to update the + // itemToKey reverse map. That read must be untracked — otherwise, + // when applyChanges is called inside an effect, it leaks an + // item->effect edge and the subsequent propagate re-runs the effect + // once during setup (transient leak, mode b). + test('applyChanges({ change }) inside an effect does not transiently re-run', () => { + type Item = { id: string; v: number } + let apply: + | ((changes: CollectionChanges) => void) + | undefined + const col = createCollection( + applyChanges => { + apply = applyChanges + return () => {} + }, + { keyConfig: item => item.id, value: [{ id: 'a', v: 1 }] }, + ) + + // Subscribe to activate the collection (triggers watched -> sets apply) + const dispose = createScope(() => { + createEffect(() => { + col.get() + }) + }) + + const trigger = createState(0) + let runs = 0 + createEffect((): undefined => { + trigger.get() + runs++ + if (runs === 1) { + // biome-ignore lint/style/noNonNullAssertion: activated above + apply!({ change: [{ id: 'a', v: 2 }] }) + } + }) + + // Without the fix, runs is 2 here (transient re-run). + expect(runs).toBe(1) + expect(col.byKey('a')?.get()).toEqual({ id: 'a', v: 2 }) + dispose() + }) + }) + describe('Input Validation', () => { test('should throw InvalidCallbackError for non-function watched', () => { expect(() => { diff --git a/test/list.test.ts b/test/list.test.ts index d5a5ce3..9741630 100644 --- a/test/list.test.ts +++ b/test/list.test.ts @@ -991,6 +991,99 @@ describe('List', () => { }) }) + describe('mutation tracking leak', () => { + // Mutation methods read child item signals internally (e.g. to diff + // old vs. new values). Those reads must be untracked — otherwise the + // caller's effect silently gains edges to every item signal touched, + // causing over-broad re-runs on unrelated item mutations. + // + // The leak has two observable modes: + // (a) persistent — leaked edges survive and a later item mutation + // re-runs the effect even though it never read the list. + // (b) transient — the mutation itself propagates through the just- + // leaked edge and re-runs the effect once during setup; after + // that re-run trimSources removes the edges. + + test('sort() inside an effect does not leak item-signal edges', () => { + const list = createList([3, 1, 2]) + const trigger = createState(0) + let runs = 0 + createEffect((): undefined => { + trigger.get() + runs++ + if (runs === 1) list.sort() + }) + + expect(runs).toBe(1) + + // Mutating an item value must NOT re-run the effect — it only + // ever read `trigger`, not the list. (persistent leak, mode a) + // biome-ignore lint/style/noNonNullAssertion: default key + list.byKey('0')!.set(99) + expect(runs).toBe(1) + }) + + test('splice() inside an effect does not leak item-signal edges', () => { + const list = createList([1, 2, 3, 4]) + const trigger = createState(0) + let runs = 0 + createEffect((): undefined => { + trigger.get() + runs++ + if (runs === 1) list.splice(1, 2) // reads keys '1','2' + }) + + expect(runs).toBe(1) + + // (persistent leak, mode a) — surviving items must not re-run + // biome-ignore lint/style/noNonNullAssertion: default key + list.byKey('0')!.set(99) + expect(runs).toBe(1) + }) + + test('update() inside an effect does not transiently re-run', () => { + // list.update calls list.get() (tracked) then list.set(). The + // tracked get() leaks an effect->list edge; the subsequent set() + // propagates through it, re-running the effect once during setup + // (transient leak, mode b). + const list = createList([1, 2, 3]) + const trigger = createState(0) + let runs = 0 + createEffect((): undefined => { + trigger.get() + runs++ + if (runs === 1) list.update(arr => [...arr, 4]) + }) + + // Without the fix, runs is 2 here (transient re-run). + expect(runs).toBe(1) + }) + + test('set() inside an effect does not leak item-signal edges (dirty node)', () => { + // A prior add() marks the node DIRTY, so set() takes the + // buildValue() path — reading each item signal tracked and + // leaking edges directly into the effect. + const list = createList([1, 2, 3]) + const trigger = createState(0) + let runs = 0 + createEffect((): undefined => { + trigger.get() + runs++ + if (runs === 1) { + list.add(4) // marks node DIRTY + list.set([1, 2, 3, 4]) + } + }) + + expect(runs).toBe(1) + + // (persistent leak, mode a) + // biome-ignore lint/style/noNonNullAssertion: default key + list.byKey('0')!.set(99) + expect(runs).toBe(1) + }) + }) + describe('Undefined handling', () => { // `undefined` elements must be rejected the same way `null` is — otherwise // `keys` grows sparse and `length` / `get()` disagree. diff --git a/test/store.test.ts b/test/store.test.ts index 5ceaf06..f810285 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -657,6 +657,31 @@ describe('Store', () => { }) }) + describe('update re-subscription leak', () => { + // store.update calls store.get() (tracked) then store.set(). The + // tracked get() leaks an effect->store edge; the subsequent set() + // propagates through it, re-running the effect once during setup + // (transient leak). After the fix, update reads the current value + // untracked, matching State.update (which reads node.value directly). + test('effect calling store.update should not transiently re-run', () => { + const store = createStore<{ name: string; age: number }>({ + name: 'Alice', + age: 30, + }) + const trigger = createState(0) + let runs = 0 + createEffect((): undefined => { + trigger.get() + runs++ + if (runs === 1) store.update(prev => ({ ...prev, age: 31 })) + }) + + // Without the fix, runs is 2 here (transient re-run). + expect(runs).toBe(1) + expect(store.age.get()).toBe(31) + }) + }) + describe('set type-change routing', () => { // When a property changes shape (primitive -> array), store.set must // route through addSignal/createList, not stuff the array into the From 2ff1c0549eb76fe9a29e9ffff775e38ef25275c9 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 18:01:30 +0200 Subject: [PATCH 04/11] fix(store): guard proxy against direct assignment, deletion, and defineProperty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct property writes on a Store proxy (store.name = 'Bob', delete store.name, Object.defineProperty, Object.assign) silently corrupted the store — the raw value shadowed the child State signal, causing store.name and store.get() to diverge. Add set, deleteProperty, and defineProperty traps that throw the new InvalidStoreMutationError, a TypeError subclass with a message naming the correct reactive alternative (store.key.set / store.set / store.add / store.remove). Routing to the child signal's .set() was considered and rejected: Store types properties as signals (State, not string) to preserve reactivity through destructuring, so proxy assignment is a compile error for typed stores — routing would be a runtime-only feature usable only behind 'as any'. See ADR-0017. - src/errors.ts: InvalidStoreMutationError class - src/nodes/store.ts: three proxy traps, import, JSDoc - index.ts: export InvalidStoreMutationError - test/store.test.ts: 6 new proxy write guard tests - adr/0017-store-proxy-rejects-direct-writes.md: ADR with full rationale - non-obvious-behaviors.md: store_proxy_rejects_direct_writes and store_method_names_shadow_data_keys entries --- .../skills/adr-keeper/references/adr-index.md | 5 +- .../references/non-obvious-behaviors.md | 42 +++++++ CHANGELOG.md | 2 + PLAN-store-proxy-write-guard.md | 96 --------------- adr/0017-store-proxy-rejects-direct-writes.md | 67 ++++++++++ index.dev.js | 115 ++++++++++++++---- index.js | 2 +- index.ts | 1 + src/errors.ts | 24 ++++ src/nodes/store.ts | 27 +++- test/store.test.ts | 68 +++++++++++ types/index.d.ts | 2 +- types/src/errors.d.ts | 25 +++- types/src/graph.d.ts | 7 +- types/src/nodes/effect.d.ts | 7 ++ types/src/nodes/store.d.ts | 12 ++ 16 files changed, 374 insertions(+), 128 deletions(-) delete mode 100644 PLAN-store-proxy-write-guard.md create mode 100644 adr/0017-store-proxy-rejects-direct-writes.md diff --git a/.agents/skills/adr-keeper/references/adr-index.md b/.agents/skills/adr-keeper/references/adr-index.md index e5ab372..210c13c 100644 --- a/.agents/skills/adr-keeper/references/adr-index.md +++ b/.agents/skills/adr-keeper/references/adr-index.md @@ -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 | |---|-----|--------|---------------------| @@ -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 | --- diff --git a/.agents/skills/shared/references/non-obvious-behaviors.md b/.agents/skills/shared/references/non-obvious-behaviors.md index b02611f..9ad9ae4 100644 --- a/.agents/skills/shared/references/non-obvious-behaviors.md +++ b/.agents/skills/shared/references/non-obvious-behaviors.md @@ -226,3 +226,45 @@ state read by A). The error surfaces synchronously from the `set()`/`batch()`/ 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. + + +**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 +``` + + + +**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({ get: 'value' }) + +store.get // () => T — the method, NOT the child State +store.byKey('get') // State — the child signal via the escape hatch +``` + +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. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 702d53c..614ada7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ ### Added - **`EffectConvergenceError`**: New error class (exported from the package root), thrown when queued effects keep re-triggering each other without settling within 1000 flush passes. Typical triggers: an effect that unconditionally writes a signal it reads (`createEffect(() => count.set(count.get() + 1))`), or two effects that write each other's dependencies. The error surfaces synchronously from the `set()`/`update()`/`batch()`/`createEffect()` call that triggered the runaway; other queued effects still run before it is thrown. +- **`InvalidStoreMutationError`**: New error class (exported from the package root), a `TypeError` subclass thrown when a `Store` property is assigned, deleted, or defined directly via the proxy (`store.name = 'Bob'`, `delete store.name`, `Object.defineProperty(store, …)`, `Object.assign(store, …)`). The message names the correct reactive alternative (`store.key.set(value)`, `store.set(next)`, `store.add(key, value)`, or `store.remove(key)`). See [ADR-0017](adr/0017-store-proxy-rejects-direct-writes.md). ### Fixed +- **Direct property assignment on a `Store` proxy silently corrupted the store** (`src/nodes/store.ts`): Previously, the proxy defined no `set`, `deleteProperty`, or `defineProperty` traps — so `store.name = 'Bob'` wrote the raw value onto the proxy target object, shadowing the child `State` signal. From then on, `store.name` returned the raw value while `store.get()` returned the reactive value, and the two diverged silently. `delete store.name` similarly bypassed `remove()` semantics. Now the three traps throw `InvalidStoreMutationError`, leaving `store.get()` and the child signal intact. **Migration:** code that previously assigned through the proxy (and silently corrupted state) must use the reactive API (`store.key.set()`, `store.set()`, `store.add()`, `store.remove()`). This is a **minor** version bump, not a patch — the throw replaces previously-undetected silent corruption. See [ADR-0017](adr/0017-store-proxy-rejects-direct-writes.md). - **A throwing effect no longer skips sibling effects in the same flush** (`src/graph.ts`): Previously, an exception from one effect's callback propagated out of `flush()` immediately — all effects queued after it were silently skipped (their DOM updates and subscriptions lost until some arbitrary later flush), and the throwing effect was left stuck with `FLAG_RUNNING` set, so its next `refresh()` threw a spurious `CircularDependencyError`. Now `flush()` catches per-effect errors, drains the entire queue, and rethrows after: a single error is rethrown as-is (error identity preserved for existing `catch` code), multiple errors are wrapped in an `AggregateError`. `runEffect()` clears `FLAG_RUNNING` in its `finally`, so a previously-throwing effect re-runs normally on the next update. - **Effects that write signals they depend on now converge** (`src/graph.ts`, `src/nodes/effect.ts`): Previously, `runEffect()` unconditionally overwrote the node's flags with `FLAG_CLEAN` after running, clobbering the dirty re-mark set by the effect's own write — so even a converging clamp effect (`if (v > 10) s.set(10)`) ended one run stale, having rendered the pre-clamp value while the signal held the clamped one; two subscribers of the same signal could disagree. Now `flush()` drains `queuedEffects` in passes over snapshots (effects re-queued during a pass run in the next pass), `propagate()` preserves `FLAG_RUNNING` on effects, and `runEffect()` preserves `FLAG_DIRTY`/`FLAG_CHECK` from the effect's own writes — a self-writing effect re-runs until the graph settles and its last run always observes the final signal values. Creation-time self-writes converge through the same path via a new internal `scheduleEffect()`, which also removes a re-entrant `runEffect` hazard (a write during an effect's creation run previously re-entered the still-running effect via the nested flush). - **Mutual effect writes no longer hang the process** (`src/graph.ts`): Previously, two effects writing each other's dependencies re-queued each other forever inside one `flush()`, growing the queue until heap exhaustion — there was no loop guard. Now the flush-pass cap (1000) converts this into a loud `EffectConvergenceError` while sibling effects still run. diff --git a/PLAN-store-proxy-write-guard.md b/PLAN-store-proxy-write-guard.md deleted file mode 100644 index d24e45f..0000000 --- a/PLAN-store-proxy-write-guard.md +++ /dev/null @@ -1,96 +0,0 @@ -# PLAN: Store Proxy Write Guard (reject direct assignment / delete) - -## Goal - -Direct property assignment on a `Store` proxy currently **corrupts the store silently**. - -**Confirmed defect (reproduced on `next`, 2026-07-10):** - -```ts -const store = createStore({ name: 'Alice' }) -;(store as any).name = 'Bob' // no error! -store.name // 'Bob' — raw string, the State signal is now shadowed -store.get() // { name: 'Alice' } — the reactive value is unchanged -``` - -The proxy in `createStore` (`src/nodes/store.ts:354–381`) defines `get`, `has`, `ownKeys`, and `getOwnPropertyDescriptor` traps but **no `set`, `deleteProperty`, or `defineProperty` traps**. The default `set` behavior writes the raw value onto the `BaseStore` target object; from then on `prop in target` is true, so the `get` trap returns the raw value instead of the child signal, `has` reports it, and the store's reactive state diverges from what property access shows. `delete store.name` similarly bypasses `remove()` semantics (it deletes nothing reactive but returns `true`). - -Fix: make misuse loud. Assignment and deletion through the proxy throw a descriptive error pointing at the correct API (`store.key.set(v)` / `store.set({...})` / `store.add(k, v)` / `store.remove(k)`). This matches the library's existing philosophy of throwing typed, descriptive errors on misuse (`ReadonlySignalError`, `DuplicateKeyError`, `InvalidSignalValueError`). - -Deliberately **not** routing `store.name = 'Bob'` to `signals.get('name').set('Bob')`: that would be a new write API, needs shape-category replacement logic (primitive→array transitions, see `applyChanges` at `src/nodes/store.ts:226`), changes the public contract, and per REQUIREMENTS.md new surface is added reluctantly. Throwing is the safe, non-committal fix; routing can be a future ADR. - -## Files to touch - -- `src/errors.ts` — new error class -- `src/nodes/store.ts` — add proxy traps -- `index.ts` — export the new error class -- `test/store.test.ts` — new tests -- `.agents/skills/shared/references/non-obvious-behaviors.md` — new entry for method-name shadowing (step 5) -- `adr/` — new ADR via the adr-keeper skill (step 6; optional if the user prefers no ADR) - -## Implementation steps - -1. **Add an error class** in `src/errors.ts`, following the existing style (see `ReadonlySignalError` at line 94): - - ```ts - /** - * Error thrown when a Store property is assigned or deleted directly via the proxy. - */ - class InvalidStoreMutationError extends TypeError { - constructor(prop: string, hint: string) { - super(`[Store] Cannot ${hint} property "${prop}" directly`) - this.name = 'InvalidStoreMutationError' - } - } - ``` - - Adjust the message to include guidance, e.g. for set: `` `[Store] Cannot assign to property "${prop}" directly — use store.${prop}.set(value), store.set(next), or store.add(key, value)` ``. Keep one class with a parameterized message rather than two classes. Export it from `src/errors.ts` and re-export from `index.ts` (alphabetical position within the existing error export block). - -2. **Add traps to the Proxy in `createStore`** (`src/nodes/store.ts`, inside `new Proxy(store, { ... })`): - - ```ts - set(_target, prop) { - throw new InvalidStoreMutationError(String(prop), 'assign to') - }, - deleteProperty(_target, prop) { - throw new InvalidStoreMutationError(String(prop), 'delete') - }, - defineProperty(_target, prop) { - throw new InvalidStoreMutationError(String(prop), 'define') - }, - ``` - - (Exact message wording per step 1; the three hints should produce messages that name the right alternative: `set()` family for assign/define, `store.remove(key)` for delete.) - -3. **Import** `InvalidStoreMutationError` in `store.ts` (extend the existing `import { DuplicateKeyError, validateSignalValue } from '../errors'` line). - -4. **Tests** in `test/store.test.ts`: - - `store.name = 'Bob'` throws `InvalidStoreMutationError`; afterwards `store.name` is still the `State` signal (`isState(store.name)` true) and `store.get()` unchanged. - - `delete (store as any).name` throws; `store.get()` unchanged; `store.remove('name')` still works. - - `Object.assign(store, { name: 'Bob' })` throws (goes through the `set` trap). - - `Object.defineProperty(store, 'x', { value: 1 })` throws. - - Regression: reads through the proxy (`store.name.get()`), `keys()`, iteration, spread via `ownKeys` still work (existing tests cover most of this; run them). - -5. **Document the method-name shadowing footgun** discovered while exploring: a store created with a data key named like a base method (`get`, `set`, `keys`, `update`, `add`, `remove`, `byKey`) is unreachable via proxy access — `store.get` returns the method (because the `get` trap checks `prop in target` first) — and only reachable via `store.byKey('get')`. This is inherent to the proxy design and NOT changed by this plan. Add a short entry to `.agents/skills/shared/references/non-obvious-behaviors.md` (follow the existing XML-tag entry format, e.g. ``) showing the `byKey` escape hatch, and a one-sentence note in the `createStore` JSDoc in `src/nodes/store.ts`. - -6. **Record the decision.** Invoke the adr-keeper skill to add an ADR ("Store proxy rejects direct writes") covering: the silent-divergence defect, the throw-vs-route alternatives, and why routing was deferred. If executing this plan in a context without the skill, create `adr/0017-store-proxy-rejects-direct-writes.md` following the structure of `adr/0015-*.md` (Status/Context/Decision/Alternatives Considered/Consequences) and add it to `.agents/skills/adr-keeper/references/adr-index.md`. - -7. Run `bun test`, `bun run regression`, `bunx biome lint .`, `bunx tsc --noEmit`. - -## Edge cases a weaker model would likely miss - -- **Strict-mode proxies require `set` to return `true` or throw.** Returning `false` would make consumers see a generic `TypeError: 'set' on proxy: trap returned falsish` — always throw the descriptive error instead; never `return false`. -- **The traps must throw for symbol properties too** (`store[Symbol.for('x')] = 1`). `String(prop)` handles symbols safely (`Symbol(x)` stringifies via `String()`, not via implicit coercion which would throw). Do not special-case symbols to silently succeed — a symbol write would hit the same shadowing bug via the default behavior. Note: the `get` trap intentionally returns `undefined` for unknown symbols; that stays. -- **`Reflect.set(store, k, v)` and `Object.assign(store, ...)` route through the same `set` trap** — no separate handling needed, but the `Object.assign` test matters because it's the realistic accident (merging "plain object" patterns onto a store). -- **Do not add the traps to the `BaseStore` target itself or freeze the target.** The proxy is the only public handle; internal code never assigns properties onto `store` after construction (verified: `createStore` builds the full object literal once). Freezing would break nothing today but adds risk with zero benefit. -- **This is technically a behavior break**: code that previously "worked" by assigning (and silently corrupting) will now throw. That is the point — but it should be listed under a **minor** version bump with a changelog entry (changelog-keeper skill), not a patch. -- **TypeScript already flags these writes** for typed stores (`Store` properties are typed as signals, so `store.name = 'Bob'` is a type error when `T['name']` is `string`) — the runtime guard matters for `any`-typed access, JS consumers, and `Object.assign`. Tests need `as any` casts or `@ts-expect-error` — prefer `@ts-expect-error` with a reason comment, matching repo test style. -- **`exactOptionalPropertyTypes` and `noUncheckedIndexedAccess` are on** — when writing the error class and traps, `prop` is `string | symbol`; handle the union explicitly, don't assume `string`. -- **Bundle size**: one small class + three traps ≈ 200 B minified; limits have headroom, verify with `bun run regression`. - -## Acceptance criteria - -1. All four new throwing tests pass; the silent-corruption reproduction now throws `InvalidStoreMutationError` and leaves `store.get()` and `isState(store.name)` intact. -2. `InvalidStoreMutationError` is exported from the package barrel (`import { InvalidStoreMutationError } from '@zeix/cause-effect'` type-checks). -3. All pre-existing tests pass; `bun run regression`, `bunx tsc --noEmit`, `bunx biome lint .` all clean. -4. Non-obvious-behaviors reference has the new shadowing entry; ADR exists and is indexed. diff --git a/adr/0017-store-proxy-rejects-direct-writes.md b/adr/0017-store-proxy-rejects-direct-writes.md new file mode 100644 index 0000000..e46ce12 --- /dev/null +++ b/adr/0017-store-proxy-rejects-direct-writes.md @@ -0,0 +1,67 @@ +# ADR 0017: Store Proxy Rejects Direct Writes + +## Status + +✅ Accepted + +## Context + +The `Store` proxy in `createStore` (`src/nodes/store.ts`) defined `get`, `has`, `ownKeys`, and `getOwnPropertyDescriptor` traps but **no `set`, `deleteProperty`, or `defineProperty` traps**. The default `set` behavior writes a raw value onto the `BaseStore` target object. Once written, `prop in target` is `true`, so the `get` trap returns the raw value instead of the child signal — the store's reactive state silently diverges from what property access shows: + +```ts +const store = createStore({ name: 'Alice' }) +;(store as any).name = 'Bob' // no error +store.name // 'Bob' — raw string shadows the State signal +store.get() // { name: 'Alice' } — reactive value unchanged +``` + +`delete store.name` similarly bypassed `remove()` semantics: it deleted nothing reactive but returned `true`. + +The library's existing philosophy is to throw typed, descriptive errors on misuse (`ReadonlySignalError`, `DuplicateKeyError`, `InvalidSignalValueError`). A silent corruption is the worst outcome — the guard makes misuse loud. + +Relevant: [Explicit Reactivity](REQUIREMENTS.md#explicit-reactivity), [Minimal Surface, Maximum Coverage](REQUIREMENTS.md#minimal-surface-maximum-coverage), [Bundle Size](REQUIREMENTS.md#bundle-size). + +## Decision + +Add `set`, `deleteProperty`, and `defineProperty` traps to the `Store` proxy that throw `InvalidStoreMutationError` (a `TypeError` subclass). The error message names the correct alternative API: + +- Assignment / define → `` use store.${prop}.set(value), store.set(next), or store.add(key, value) `` +- Deletion → `` use store.remove("prop") `` + +A single parameterized error class covers all three actions. It is exported from the package barrel (`index.ts`). + +Deliberately **not** routing `store.name = 'Bob'` to `signals.get('name').set('Bob')`. That would be a runtime convenience feature that contradicts the type system (see Alternatives). Throwing is the safe, non-committal fix; routing can be revisited as part of a future v2 if the type model itself changes. + +## Alternatives Considered + +- **(a) Route assignment to the child signal's `.set()`**: Rejected. The decisive obstacle is the **type system**, not the runtime logic. Cause & Effect's `Store` maps properties to signals, not raw values: + + ```ts + type Store = BaseStore & { [K in keyof T]: State } + ``` + + So for `Store<{ name: string }>`, the type of `store.name` is `State`, not `string`. Assignment `store.name = 'Bob'` is therefore a **compile-time error** — you cannot assign `string` to `State`. Routing writes at runtime would create a feature usable only behind `as any` or `@ts-expect-error`, at which point the consumer might as well call `store.name.set('Bob')`, which type-checks cleanly. + + This is fundamentally different from SolidJS, where `store.name` is typed as the raw value (`string`), so proxy assignment type-checks and routes at runtime. Cause & Effect deliberately chose the "leaf properties are signals" type model because the alternative — properties typed as raw values — would mean that destructuring a store loses reactivity (the deconstructed value is a plain primitive, disconnected from the graph). The other alternative — deconstructed values changing type from primitive to signal depending on access pattern — was also considered unacceptable. Neither option was acceptable, so properties are signals. That choice makes write-routing type-contradictory. + + This type model is inherent to the library's design and cannot be changed without a major version bump. A clean solution for deconstruction and iteration that doesn't break the type contract is not yet identified. + +- **(b) Route `deleteProperty` only, throw on `set`/`defineProperty`**: Rejected. `delete store.name` does map cleanly to `store.remove('name')` — there's no shape-category ambiguity, and `remove()` handles non-existent keys gracefully. But allowing deletion through the proxy while rejecting assignment creates **inconsistent proxy semantics**: consumers must learn that one mutation path works and another doesn't, with no principled distinction visible from the outside. The inconsistency is worse than a uniform error that redirects to the correct API. +- **(c) Return `false` from the `set` trap (trigger a generic `TypeError`)**: Rejected. Strict-mode proxies require `set` to return `true` or throw; returning `false` produces a generic `'set' on proxy: trap returned falsish` error with no guidance. Throwing the descriptive error is strictly better. +- **(d) Freeze the `BaseStore` target**: Rejected. Freezing adds risk with zero benefit — the proxy is the only public handle, and internal code never assigns properties onto the store after construction. +- **(e) Throw on direct writes** *(chosen)*: Fully consistent with the type system and the Explicit Reactivity principle. The type system already tells TS consumers that proxy writes are wrong; the runtime guard extends that protection to `any`-typed access, JS consumers, and `Object.assign`. The descriptive error redirects to the correct reactive API. + +## Consequences + +- ✅ **Silent corruption eliminated**: `store.name = 'Bob'` now throws immediately instead of shadowing the child signal. `store.get()` and `isState(store.name)` remain intact. +- ✅ **Descriptive errors**: `InvalidStoreMutationError` messages point consumers at the correct reactive API (`store.prop.set()`, `store.set()`, `store.add()`, `store.remove()`). +- ✅ **Covers realistic accidents**: `Object.assign(store, ...)` and `Reflect.set(store, ...)` route through the same `set` trap — no separate handling needed. +- ✅ **Minimal cost**: one small `TypeError` subclass + three one-line traps ≈ 200 B minified; within bundle size limits. +- ⚠️ **Behavior break (minor)**: code that previously "worked" by assigning (and silently corrupting) will now throw. This is the intended fix. It warrants a **minor** version bump (not patch), with a changelog entry. +- ⚠️ **TypeScript already flags typed writes**: `Store` properties are typed as signals, so `store.name = 'Bob'` is a compile error when `T['name']` is `string`. The runtime guard matters for `any`-typed access, JS consumers, and `Object.assign`. + +## Related + +- Requirements: [Explicit Reactivity](REQUIREMENTS.md#explicit-reactivity), [Minimal Surface, Maximum Coverage](REQUIREMENTS.md#minimal-surface-maximum-coverage), [Bundle Size](REQUIREMENTS.md#bundle-size) +- Architecture: [Composite Signal Types](ARCHITECTURE.md#composite-signal-types) +- Dependencies: [Composite Lookup Methods Track Structural Changes (ADR-0015)](0015-composite-lookup-methods-track-structural-changes.md) diff --git a/index.dev.js b/index.dev.js index 056f815..eee51ae 100644 --- a/index.dev.js +++ b/index.dev.js @@ -39,6 +39,13 @@ class CircularDependencyError extends Error { } } +class EffectConvergenceError extends Error { + constructor(passes) { + super(`[Effect] Effects did not settle after ${passes} flush passes — check for effects that write to signals they depend on`); + this.name = "EffectConvergenceError"; + } +} + class NullishSignalValueError extends TypeError { constructor(where) { super(`[${where}] Signal value cannot be null or undefined`); @@ -94,6 +101,14 @@ class DuplicateKeyError extends Error { this.name = "DuplicateKeyError"; } } + +class InvalidStoreMutationError extends TypeError { + constructor(prop, action) { + const guidance = action === "delete" ? `use store.remove(${JSON.stringify(prop)})` : `use store.${prop}.set(value), store.set(next), or store.add(key, value)`; + super(`[Store] Cannot ${action} property "${prop}" directly — ${guidance}`); + this.name = "InvalidStoreMutationError"; + } +} function validateSignalValue(where, value, guard) { if (value == null) throw new NullishSignalValueError(where); @@ -268,7 +283,7 @@ function propagate(node, newFlag = FLAG_DIRTY) { if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return; const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK); - node.flags = newFlag; + node.flags = flags & FLAG_RUNNING | newFlag; if (!wasQueued) queuedEffects.push(node); } @@ -396,8 +411,8 @@ function runEffect(node) { activeSink = prevContext; activeOwner = prevOwner; trimSources(node); + node.flags &= FLAG_DIRTY | FLAG_CHECK; } - node.flags = FLAG_CLEAN; } function refresh(node) { if (node.flags & FLAG_CHECK) { @@ -422,20 +437,54 @@ function refresh(node) { node.flags = FLAG_CLEAN; } } +var MAX_FLUSH_PASSES = 1000; function flush() { if (flushing) return; flushing = true; + let errors; + let passes = 0; try { - for (let i = 0;i < queuedEffects.length; i++) { - const effect = queuedEffects[i]; - if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) - refresh(effect); + while (queuedEffects.length > 0) { + if (++passes > MAX_FLUSH_PASSES) { + queuedEffects.length = 0; + if (!errors) + errors = []; + errors.push(new EffectConvergenceError(MAX_FLUSH_PASSES)); + break; + } + const batch = queuedEffects.slice(); + queuedEffects.length = 0; + for (let i = 0;i < batch.length; i++) { + const effect = batch[i]; + if (effect.flags & FLAG_RUNNING) + continue; + if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) { + try { + refresh(effect); + } catch (err) { + if (!errors) + errors = []; + errors.push(err); + } + } + } } - queuedEffects.length = 0; } finally { flushing = false; } + if (errors) { + if (errors.length === 1) + throw errors[0]; + throw new AggregateError(errors, "Multiple effects threw during flush"); + } +} +function scheduleEffect(node) { + if (node.flags & (FLAG_DIRTY | FLAG_CHECK)) { + queuedEffects.push(node); + if (batchDepth === 0) + flush(); + } } function batch(fn) { batchDepth++; @@ -727,7 +776,7 @@ function createList(value, options) { return node.value; }, set(next) { - const prev = node.flags & FLAG_DIRTY ? buildValue() : node.value; + const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value; const changes = diffArrays(prev, next, keys, generateKey, contentBased, itemEquals); if (changes.changed) { keys = changes.newKeys; @@ -740,7 +789,7 @@ function createList(value, options) { } }, update(fn) { - list.set(fn(list.get())); + list.set(fn(untrack(() => list.get()))); }, at(index) { subscribe(); @@ -811,11 +860,13 @@ function createList(value, options) { }, sort(compareFn) { const entries = []; - for (const key of keys) { - const v = signals.get(key)?.get(); - if (v !== undefined) - entries.push([key, v]); - } + untrack(() => { + for (const key of keys) { + const v = signals.get(key)?.get(); + if (v !== undefined) + entries.push([key, v]); + } + }); entries.sort(isFunction(compareFn) ? (a, b) => compareFn(a[1], b[1]) : (a, b) => String(a[1]).localeCompare(String(b[1]))); const newOrder = []; for (const [key] of entries) @@ -836,17 +887,19 @@ function createList(value, options) { const add = {}; const remove = {}; let hasRemove = false; - for (let i = 0;i < actualDeleteCount; i++) { - const index = actualStart + i; - const key = keys[index]; - if (key) { - const signal = signals.get(key); - if (signal) { - remove[key] = signal.get(); - hasRemove = true; + untrack(() => { + for (let i = 0;i < actualDeleteCount; i++) { + const index = actualStart + i; + const key = keys[index]; + if (key) { + const signal = signals.get(key); + if (signal) { + remove[key] = signal.get(); + hasRemove = true; + } } } - } + }); const newOrder = keys.slice(0, actualStart); const change = {}; let hasAdd = false; @@ -1224,7 +1277,7 @@ function createCollection(watched, options) { continue; const signal = signals.get(key); if (signal && isState(signal)) { - itemToKey.delete(signal.get()); + itemToKey.delete(untrack(() => signal.get())); signal.set(item); itemToKey.set(item, key); } @@ -1336,6 +1389,7 @@ function createEffect(fn) { if (activeOwner) registerCleanup(activeOwner, dispose); runEffect(node); + scheduleEffect(node); return dispose; } function match(signalOrSignals, handlers) { @@ -1579,7 +1633,7 @@ function createStore(value, options) { } }, update(fn) { - store.set(fn(store.get())); + store.set(fn(untrack(() => store.get()))); }, add(key, value2) { if (signals.has(key)) @@ -1610,6 +1664,15 @@ function createStore(value, options) { if (typeof prop !== "symbol") return target.byKey(prop); }, + set(_target, prop) { + throw new InvalidStoreMutationError(String(prop), "assign to"); + }, + deleteProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), "delete"); + }, + defineProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), "define"); + }, has(target, prop) { if (prop in target) return true; @@ -1798,8 +1861,10 @@ export { ReadonlySignalError, PromiseValueError, NullishSignalValueError, + InvalidStoreMutationError, InvalidSignalValueError, InvalidCallbackError, + EffectConvergenceError, DuplicateKeyError, DEFAULT_EQUALITY, DEEP_EQUALITY, diff --git a/index.js b/index.js index 4a13a0c..482ef63 100644 --- a/index.js +++ b/index.js @@ -1 +1 @@ -var dz=Object.getPrototypeOf(async()=>{});function c(z){return typeof z==="function"}function Jz(z){return c(z)&&Object.getPrototypeOf(z)===dz}function Wz(z){return c(z)&&Object.getPrototypeOf(z)!==dz}function oz(z,J){return Object.prototype.toString.call(z)===`[object ${J}]`}function A(z,J){return z!=null&&z[Symbol.toStringTag]===J}function k(z){return z!==null&&typeof z==="object"&&Object.getPrototypeOf(z)===Object.prototype}function Oz(z){if(typeof z==="string")return`"${z}"`;if(z!=null&&typeof z==="object")try{return JSON.stringify(z)}catch{return String(z)}return String(z)}class wz extends Error{constructor(z){super(`[${z}] Circular dependency detected`);this.name="CircularDependencyError"}}class Nz extends TypeError{constructor(z){super(`[${z}] Signal value cannot be null or undefined`);this.name="NullishSignalValueError"}}class Xz extends Error{constructor(z){super(`[${z}] Signal value is unset`);this.name="UnsetSignalValueError"}}class Hz extends TypeError{constructor(z,J){super(`[${z}] Signal value ${Oz(J)} is invalid`);this.name="InvalidSignalValueError"}}class Lz extends TypeError{constructor(z,J){super(`[${z}] Callback ${Oz(J)} is invalid`);this.name="InvalidCallbackError"}}class Cz extends Error{constructor(z){super(`[${z}] Signal is read-only`);this.name="ReadonlySignalError"}}class xz extends Error{constructor(z){super(`[${z}] Active owner is required`);this.name="RequiredOwnerError"}}class Fz extends TypeError{constructor(z){super(`[${z}] Callback returned a Promise — use an async callback to create a Task instead`);this.name="PromiseValueError"}}class u extends Error{constructor(z,J,X){super(`[${z}] Could not add key "${J}"${X!=null?` with value ${JSON.stringify(X)}`:""} because it already exists`);this.name="DuplicateKeyError"}}function O(z,J,X){if(J==null)throw new Nz(z);if(X&&!X(J))throw new Hz(z,J)}function Bz(z,J){if(J==null)throw new Xz(z)}function h(z,J,X=c){if(!X(J))throw new Lz(z,J)}var r="State",l="Memo",n="Task",a="Sensor",_="List",t="Collection",e="Store",zz="Slot",T=0,Zz=1,D=2,Dz=4,L=8,G=null,b=null,Iz=[],x=0,bz=!1,y=(z,J)=>z===J,Ez=(z,J)=>!1,iz=(z,J)=>Tz(z,J,new WeakSet),Tz=(z,J,X)=>{if(Object.is(z,J))return!0;if(typeof z!==typeof J)return!1;if(z==null||typeof z!=="object"||J==null||typeof J!=="object")return!1;if(X.has(z))return!0;X.add(z);try{let Z=Array.isArray(z);if(Z!==Array.isArray(J))return!1;if(Z){let B=z,U=J;if(B.length!==U.length)return!1;for(let N=0;Niz(z,J),rz=s;function nz(z,J){let X=J.sourcesTail;if(X){let Z=J.sources;while(Z){if(Z===z)return!0;if(Z===X)break;Z=Z.nextSource}}return!1}function f(z,J){let X=J.sourcesTail;if(X?.source===z)return;let Z=null,B=J.flags&Dz;if(B){if(Z=X?X.nextSource:J.sources,Z?.source===z){J.sourcesTail=Z;return}}let U=z.sinksTail;if(U?.sink===J&&(!B||nz(U,J)))return;let N={source:z,sink:J,nextSource:Z,prevSink:U,nextSink:null};if(J.sourcesTail=z.sinksTail=N,X)X.nextSource=N;else J.sources=N;if(U)U.nextSink=N;else z.sinks=N}function az(z){let{source:J,nextSource:X,nextSink:Z,prevSink:B}=z;if(Z)Z.prevSink=B;else J.sinksTail=B;if(B)B.nextSink=Z;else J.sinks=Z;if(!J.sinks){if(J.stop)J.stop(),J.stop=void 0;if("sources"in J&&J.sources){let U=J;U.sourcesTail=null,Qz(U),U.flags|=D}}return X}function Qz(z){let J=z.sourcesTail,X=J?J.nextSource:z.sources;while(X)X=az(X);if(J)J.nextSource=null;else z.sources=null}function w(z,J=D){let X=z.flags;if("sinks"in z){if((X&(D|Zz))>=J)return;if(z.flags=X|J,"controller"in z&&z.controller)z.controller.abort(),z.controller=void 0;for(let Z=z.sinks;Z;Z=Z.nextSink)w(Z.sink,Zz)}else{if((X&(D|Zz))>=J)return;let Z=X&(D|Zz);if(z.flags=J,!Z)Iz.push(z)}}function g(z,J){if(z.equals(z.value,J))return;z.value=J;for(let X=z.sinks;X;X=X.nextSink)w(X.sink);if(x===0)m()}function $z(z,J){if(!z.cleanup)z.cleanup=J;else if(Array.isArray(z.cleanup))z.cleanup.push(J);else z.cleanup=[z.cleanup,J]}function Yz(z){if(!z.cleanup)return;if(Array.isArray(z.cleanup))for(let J=0;J{if(J.signal.aborted)return;z.controller=void 0,d(()=>{if(z.error||!z.equals(B,z.value)){z.value=B,z.error=void 0;for(let U=z.sinks;U;U=U.nextSink)w(U.sink)}g(z.pendingNode,!1)})},(B)=>{if(J.signal.aborted)return;z.controller=void 0;let U=B instanceof Error?B:Error(String(B));d(()=>{if(!z.error||U.name!==z.error.name||U.message!==z.error.message){z.error=U;for(let N=z.sinks;N;N=N.nextSink)w(N.sink)}g(z.pendingNode,!1)})}),z.flags=T}function Sz(z){Yz(z);let J=G,X=b;G=b=z,z.sourcesTail=null,z.flags=Dz;try{let Z=z.fn();if(typeof Z==="function")$z(z,Z)}finally{G=J,b=X,Qz(z)}z.flags=T}function Y(z){if(z.flags&Zz)for(let J=z.sources;J;J=J.nextSource){if("fn"in J.source)Y(J.source);if(z.flags&D)break}if(z.flags&Dz)throw new wz("controller"in z?n:("value"in z)?l:"Effect");if(z.flags&D)if("controller"in z)zJ(z);else if("value"in z)ez(z);else Sz(z);else z.flags=T}function m(){if(bz)return;bz=!0;try{for(let z=0;zYz(Z);try{let U=z();if(typeof U==="function")$z(Z,U);return B}finally{if(b=X,!J?.root&&X)$z(X,B)}}function XJ(z){let J=b;b=null;try{return z()}finally{b=J}}function v(z,J){return J?()=>{if(G){if(!z.sinks)z.stop=J();f(z,G)}}:()=>{if(G)f(z,G)}}function o(z,J){O(r,z,J?.guard);let X={value:z,sinks:null,sinksTail:null,equals:J?.equals??y,guard:J?.guard};return{[Symbol.toStringTag]:r,get(){if(G)f(X,G);return X.value},set(Z){O(r,Z,X.guard),g(X,Z)},update(Z){h(r,Z);let B=Z(X.value);O(r,B,X.guard),g(X,B)}}}function Gz(z){return A(z,r)}function mz(z,J){if(z.length!==J.length)return!1;for(let X=0;X`${z}${J++}`:X?(Z)=>z(Z)||String(J++):()=>String(J++),X]}function ZJ(z,J,X,Z,B){let U={},N={},V={},P=[],Q=!1,K=Math.min(z.length,J.length);for(let j=0;jo($,{equals:N})),P=()=>{let $=[];for(let H of Z){let W=X.get(H)?.get();if(W!==void 0)$.push(W)}return $},Q={fn:P,value:z,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},K=($)=>{let H=!1;for(let M in $.add){let R=$.add[M];O(`${_} item for key "${M}"`,R),X.set(M,V(R)),H=!0}let W=!1;for(let M in $.change){W=!0;break}if(W)d(()=>{for(let M in $.change){let R=$.change[M];O(`${_} item for key "${M}"`,R);let p=X.get(M);if(p)p.set(R)}});for(let M in $.remove){X.delete(M);let R=Z.indexOf(M);if(R!==-1)Z.splice(R,1);H=!0}if(H)Q.flags|=L;return $.changed},j=v(Q,J?.watched);for(let $=0;$=0)Z.splice(M,1);Q.flags|=D|L;for(let R=Q.sinks;R;R=R.nextSink)w(R.sink);if(x===0)m()}},replace($,H){let W=X.get($);if(!W)return;if(O(`${_} item for key "${$}"`,H),N(E(()=>W.get()),H))return;if(d(()=>{W.set(H),Q.flags|=D;for(let M=Q.sinks;M;M=M.nextSink)w(M.sink)}),x===0)m()},sort($){let H=[];for(let M of Z){let R=X.get(M)?.get();if(R!==void 0)H.push([M,R])}H.sort(c($)?(M,R)=>$(M[1],R[1]):(M,R)=>String(M[1]).localeCompare(String(R[1])));let W=[];for(let[M]of H)W.push(M);if(!mz(Z,W)){Z=W,Q.flags|=D;for(let M=Q.sinks;M;M=M.nextSink)w(M.sink);if(x===0)m()}},splice($,H,...W){let M=Z.length,R=$<0?Math.max(0,M+$):Math.min($,M),p=Math.max(0,Math.min(H??Math.max(0,M-Math.max(0,R)),M-R)),jz={},C={},F=!1;for(let S=0;SZ(()=>{if(w(X),x===0)m()}):void 0);return{[Symbol.toStringTag]:l,get(){if(B(),Y(X),X.error)throw X.error;return Bz(l,X.value),X.value}}}function pz(z){return A(z,l)}function Uz(z,J){if(h(n,z,Jz),J?.value!==void 0)O(n,J.value,J?.guard);let X={value:!1,sinks:null,sinksTail:null,equals:y},Z={fn:z,value:J?.value,sources:null,sourcesTail:null,sinks:null,sinksTail:null,flags:D,equals:J?.equals??y,controller:void 0,error:void 0,stop:void 0,pendingNode:X},B=J?.watched,U=v(Z,B?()=>B(()=>{if(w(Z),x===0)m()}):void 0),N=v(X);return{[Symbol.toStringTag]:n,get(){if(U(),Y(Z),Z.error)throw Z.error;return Bz(n,Z.value),Z.value},isPending(){return N(),Z.pendingNode.value},abort(){Z.controller?.abort(),Z.controller=void 0,g(Z.pendingNode,!1)}}}function Vz(z){return A(z,n)}function Az(z,J){h(t,J);let X=Jz(J),Z=new Map,B=[],U=($)=>{let H=X?Uz(async(W,M)=>{let R=E(()=>z.byKey($));if(!R)return W;let p=R.get();if(p==null)return W;return J(p,M)}):Mz(()=>{let W=E(()=>z.byKey($));if(!W)return;let M=W.get();if(M==null)return;return J(M)});Z.set($,H)};function N($){if(!mz(B,$)){let H=new Set($);for(let W of B)if(!H.has(W))Z.delete(W);for(let W of $)if(!Z.has(W))U(W);B=$,Q.flags|=L}}function V(){N(Array.from(z.keys()));let $=[];for(let H of B)try{let W=Z.get(H)?.get();if(W!=null)$.push(W)}catch(W){if(!(W instanceof Xz))throw W}return $}let Q={fn:V,value:[],flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:($,H)=>{if($.length!==H.length)return!1;for(let W=0;W<$.length;W++)if($[W]!==H[W])return!1;return!0},error:void 0};function K(){if(Q.sources){if(Q.flags)if(Q.value=E(V),Q.flags&L){if(Q.flags=D,Y(Q),Q.error)throw Q.error}else Q.flags=T}else if(Q.sinks){if(Y(Q),Q.error)throw Q.error}else Q.value=E(V)}let j=Array.from(E(()=>z.keys()));for(let $ of j)U($);B=j;let q={[Symbol.toStringTag]:t,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){if(G)f(Q,G);K();for(let $ of B){let H=Z.get($);if(H)yield H}},get length(){if(G)f(Q,G);return K(),B.length},keys(){if(G)f(Q,G);return K(),B.values()},get(){if(G)f(Q,G);return K(),Q.value},at($){if(G)f(Q,G);K();let H=B[$];return H!==void 0?Z.get(H):void 0},byKey($){if(G)f(Q,G);return K(),Z.get($)},keyAt($){if(G)f(Q,G);return K(),B[$]},indexOfKey($){if(G)f(Q,G);return K(),B.indexOf($)},deriveCollection($){return Az(q,$)}};return q}function jJ(z,J){let X=J?.value??[];if(X.length)O(t,X,Array.isArray);h(t,z,Wz);let Z=new Map,B=[],U=new Map,[N,V]=hz(J?.keyConfig),P=(W)=>U.get(W)??(V?N(W):void 0),Q=J?.createItem??((W)=>o(W,{equals:J?.itemEquals??s}));function K(){let W=[];for(let M of B)try{let R=Z.get(M)?.get();if(R!=null)W.push(R)}catch(R){if(!(R instanceof Xz))throw R}return W}let j={fn:K,value:X,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:Ez,error:void 0};for(let W of X){let M=N(W);Z.set(M,Q(W)),U.set(W,M),B.push(M)}j.value=X,j.flags=D;let q=(W)=>{let{add:M,change:R,remove:p}=W;if(!M?.length&&!R?.length&&!p?.length)return;let jz=!1;d(()=>{if(M){let C=new Map;for(let F of M){let I=N(F);if(Z.has(I)||C.has(I))throw new u(t,I,F);C.set(I,F)}for(let[F,I]of C){if(Z.set(F,Q(I)),U.set(I,F),!B.includes(F))B.push(F);jz=!0}}if(R)for(let C of R){let F=P(C);if(!F)continue;let I=Z.get(F);if(I&&Gz(I))U.delete(I.get()),I.set(C),U.set(C,F)}if(p)for(let C of p){let F=P(C);if(!F)continue;U.delete(C),Z.delete(F);let I=B.indexOf(F);if(I!==-1)B.splice(I,1);jz=!0}j.flags=D|(jz?L:0);for(let C=j.sinks;C;C=C.nextSink)w(C.sink)})},$=v(j,()=>z(q)),H={[Symbol.toStringTag]:t,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){$();for(let W of B){let M=Z.get(W);if(M)yield M}},get length(){return $(),B.length},keys(){return $(),B.values()},get(){if($(),j.sources){if(j.flags){let W=j.flags&L;if(j.value=E(K),W){if(j.flags=D,Y(j),j.error)throw j.error}else j.flags=T}}else if(Y(j),j.error)throw j.error;return j.value},at(W){$();let M=B[W];return M!==void 0?Z.get(M):void 0},byKey(W){return $(),Z.get(W)},keyAt(W){return $(),B[W]},indexOfKey(W){return $(),B.indexOf(W)},deriveCollection(W){return Az(H,W)}};return H}function WJ(z){return A(z,t)}function HJ(z){h("Effect",z);let J={fn:z,flags:D,sources:null,sourcesTail:null,cleanup:null},X=()=>{Yz(J),J.fn=void 0,J.flags=T,J.sourcesTail=null,Qz(J)};if(b)$z(b,X);return Sz(J),X}function BJ(z,J){if(!b)throw new xz("match");let X=!Array.isArray(z),Z=X?[z]:z,{nil:B,stale:U}=J,N=X?(q)=>J.ok(q[0]):(q)=>J.ok(q),V=X&&J.err?(q)=>J.err(q[0]):J.err??console.error,P,Q=!1,K=Array(Z.length);for(let q=0;qVz(q)&&q.isPending())))j=U();else j=N(K)}catch(q){j=V([q instanceof Error?q:Error(String(q))])}if(typeof j==="function")return j;if(j instanceof Promise){let q=b,$=new AbortController;$z(q,()=>$.abort()),j.then((H)=>{if(!$.signal.aborted&&typeof H==="function")$z(q,H)}).catch((H)=>{V([H instanceof Error?H:Error(String(H))])})}}function QJ(z,J){if(h(a,z,Wz),J?.value!==void 0)O(a,J.value,J?.guard);let X={value:J?.value,sinks:null,sinksTail:null,equals:J?.equals??y,guard:J?.guard,stop:void 0};return{[Symbol.toStringTag]:a,get(){if(G){if(!X.sinks)X.stop=z((Z)=>{O(a,Z,X.guard),g(X,Z)});f(X,G)}return Bz(a,X.value),X.value}}}function qJ(z){return A(z,a)}function MJ(z,J){let X={},Z={},B={},U=!1,N=Object.keys(z),V=Object.keys(J);for(let P of V)if(P in z){if(!s(z[P],J[P]))Z[P]=J[P],U=!0}else X[P]=J[P],U=!0;for(let P of N)if(!(P in J))B[P]=void 0,U=!0;return{add:X,change:Z,remove:B,changed:U}}function Rz(z,J){O(e,z,k);let X=new Map,Z=(j,q)=>{if(O(`${e} for key "${j}"`,q),Array.isArray(q))X.set(j,qz(q));else if(k(q))X.set(j,Rz(q));else X.set(j,o(q))},B=(j)=>{if(Array.isArray(j))return"list";if(k(j))return"store";return"state"},U=(j)=>{if(Pz(j))return"list";if(fz(j))return"store";return"state"},N=()=>{let j={};for(let[q,$]of X)j[q]=$.get();return j},V={fn:N,value:z,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},P=(j)=>{let q=!1;for(let H in j.add)Z(H,j.add[H]),q=!0;let $=!1;for(let H in j.change){$=!0;break}if($)d(()=>{for(let H in j.change){let W=j.change[H];O(`${e} for key "${H}"`,W);let M=X.get(H);if(M)if(B(W)!==U(M))Z(H,W),q=!0;else M.set(W)}});for(let H in j.remove)X.delete(H),q=!0;if(q)V.flags|=L;return j.changed},Q=v(V,J?.watched);for(let j of Object.keys(z))Z(j,z[j]);let K={[Symbol.toStringTag]:e,[Symbol.isConcatSpreadable]:!1,*[Symbol.iterator](){Q();for(let[j,q]of X)yield[j,q]},keys(){return Q(),X.keys()},byKey(j){return X.get(j)},get(){if(Q(),V.sources){if(V.flags){let j=V.flags&L;if(V.value=E(N),j){if(V.flags=D,Y(V),V.error)throw V.error}else V.flags=T}}else if(Y(V),V.error)throw V.error;return V.value},set(j){let q=V.flags&D?E(N):V.value,$=MJ(q,j);if(P($)){V.flags|=D;for(let H=V.sinks;H;H=H.nextSink)w(H.sink);if(x===0)m()}},update(j){K.set(j(K.get()))},add(j,q){if(X.has(j))throw new u(e,j,q);Z(j,q),V.flags|=D|L;for(let $=V.sinks;$;$=$.nextSink)w($.sink);if(x===0)m();return j},remove(j){if(X.delete(j)){V.flags|=D|L;for(let $=V.sinks;$;$=$.nextSink)w($.sink);if(x===0)m()}}};return new Proxy(K,{get(j,q){if(q in j)return Reflect.get(j,q);if(typeof q!=="symbol")return j.byKey(q)},has(j,q){if(q in j)return!0;return j.byKey(String(q))!==void 0},ownKeys(j){return Array.from(j.keys())},getOwnPropertyDescriptor(j,q){if(q in j)return Reflect.getOwnPropertyDescriptor(j,q);if(typeof q==="symbol")return;let $=j.byKey(String(q));return $?{enumerable:!0,configurable:!0,writable:!0,value:$}:void 0}})}function fz(z){return A(z,e)}var UJ=new Set([r,l,n,a,zz,_,t,e]);function VJ(z,J){return Jz(z)?Uz(z,J):Mz(z,J)}function NJ(z){if(Kz(z))return z;if(z==null)throw new Hz("createSignal",z);if(Jz(z))return Uz(z);if(c(z))return Mz(z);if(Array.isArray(z)&&z.every((J)=>J!=null))return qz(z);if(k(z))return Rz(z);return o(z)}function DJ(z){if(lz(z))return z;if(z==null||c(z)||Kz(z))throw new Hz("createMutableSignal",z);if(Array.isArray(z)&&z.every((J)=>J!=null))return qz(z);if(k(z))return Rz(z);return o(z)}function GJ(z){return pz(z)||Vz(z)}function Kz(z){return z!=null&&UJ.has(z[Symbol.toStringTag])}function lz(z){return Gz(z)||fz(z)||Pz(z)}var yz=new WeakSet;function tz(z){if(Kz(z))return!0;return z!==null&&typeof z==="object"&&"get"in z&&typeof z.get==="function"}function PJ(z,J){O(zz,z,tz);let X=z,Z=J?.guard,B={fn:()=>X.get(),value:void 0,flags:D,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:J?.equals??y,error:void 0},U=()=>{if(G)f(B,G);if(Y(B),B.error)throw B.error;return B.value},N=(P)=>{if(yz.has(B))throw Error("[Slot] Circular delegation detected in set()");yz.add(B);try{if(sz(X))return void X.set(P);if("set"in X&&typeof X.set==="function")O(zz,P,Z),X.set(P);else throw new Cz(zz)}finally{yz.delete(B)}},V=(P)=>{O(zz,P,tz),X=P,B.flags|=D;for(let Q=B.sinks;Q;Q=Q.nextSink)w(Q.sink);if(x===0)m()};return{[Symbol.toStringTag]:zz,configurable:!0,enumerable:!0,get:U,set:N,replace:V,current:()=>X}}function sz(z){return A(z,zz)}export{Oz as valueString,E as untrack,XJ as unown,BJ as match,Vz as isTask,fz as isStore,Gz as isState,sz as isSlot,A as isSignalOfType,Kz as isSignal,qJ as isSensor,k as isRecord,oz as isObjectOfType,lz as isMutableSignal,pz as isMemo,Pz as isList,c as isFunction,rz as isEqual,GJ as isComputed,WJ as isCollection,Jz as isAsyncFunction,Uz as createTask,Rz as createStore,o as createState,PJ as createSlot,NJ as createSignal,QJ as createSensor,JJ as createScope,DJ as createMutableSignal,Mz as createMemo,qz as createList,HJ as createEffect,VJ as createComputed,jJ as createCollection,d as batch,Xz as UnsetSignalValueError,Ez as SKIP_EQUALITY,xz as RequiredOwnerError,Cz as ReadonlySignalError,Fz as PromiseValueError,Nz as NullishSignalValueError,Hz as InvalidSignalValueError,Lz as InvalidCallbackError,u as DuplicateKeyError,y as DEFAULT_EQUALITY,s as DEEP_EQUALITY,wz as CircularDependencyError}; +var iz=Object.getPrototypeOf(async()=>{});function c(z){return typeof z==="function"}function Zz(z){return c(z)&&Object.getPrototypeOf(z)===iz}function Bz(z){return c(z)&&Object.getPrototypeOf(z)!==iz}function az(z,J){return Object.prototype.toString.call(z)===`[object ${J}]`}function f(z,J){return z!=null&&z[Symbol.toStringTag]===J}function k(z){return z!==null&&typeof z==="object"&&Object.getPrototypeOf(z)===Object.prototype}function xz(z){if(typeof z==="string")return`"${z}"`;if(z!=null&&typeof z==="object")try{return JSON.stringify(z)}catch{return String(z)}return String(z)}class Cz extends Error{constructor(z){super(`[${z}] Circular dependency detected`);this.name="CircularDependencyError"}}class Fz extends Error{constructor(z){super(`[Effect] Effects did not settle after ${z} flush passes — check for effects that write to signals they depend on`);this.name="EffectConvergenceError"}}class Pz extends TypeError{constructor(z){super(`[${z}] Signal value cannot be null or undefined`);this.name="NullishSignalValueError"}}class $z extends Error{constructor(z){super(`[${z}] Signal value is unset`);this.name="UnsetSignalValueError"}}class Hz extends TypeError{constructor(z,J){super(`[${z}] Signal value ${xz(J)} is invalid`);this.name="InvalidSignalValueError"}}class Tz extends TypeError{constructor(z,J){super(`[${z}] Callback ${xz(J)} is invalid`);this.name="InvalidCallbackError"}}class Yz extends Error{constructor(z){super(`[${z}] Signal is read-only`);this.name="ReadonlySignalError"}}class Iz extends Error{constructor(z){super(`[${z}] Active owner is required`);this.name="RequiredOwnerError"}}class mz extends TypeError{constructor(z){super(`[${z}] Callback returned a Promise — use an async callback to create a Task instead`);this.name="PromiseValueError"}}class d extends Error{constructor(z,J,Z){super(`[${z}] Could not add key "${J}"${Z!=null?` with value ${JSON.stringify(Z)}`:""} because it already exists`);this.name="DuplicateKeyError"}}class Qz extends TypeError{constructor(z,J){let Z=J==="delete"?`use store.remove(${JSON.stringify(z)})`:`use store.${z}.set(value), store.set(next), or store.add(key, value)`;super(`[Store] Cannot ${J} property "${z}" directly — ${Z}`);this.name="InvalidStoreMutationError"}}function O(z,J,Z){if(J==null)throw new Pz(z);if(Z&&!Z(J))throw new Hz(z,J)}function qz(z,J){if(J==null)throw new $z(z)}function h(z,J,Z=c){if(!Z(J))throw new Tz(z,J)}var n="State",l="Memo",a="Task",e="Sensor",L="List",i="Collection",zz="Store",Jz="Slot",S=0,r=1,N=2,Xz=4,b=8,G=null,T=null,Mz=[],x=0,Ez=!1,p=(z,J)=>z===J,Sz=(z,J)=>!1,ez=(z,J)=>hz(z,J,new WeakSet),hz=(z,J,Z)=>{if(Object.is(z,J))return!0;if(typeof z!==typeof J)return!1;if(z==null||typeof z!=="object"||J==null||typeof J!=="object")return!1;if(Z.has(z))return!0;Z.add(z);try{let $=Array.isArray(z);if($!==Array.isArray(J))return!1;if($){let B=z,U=J;if(B.length!==U.length)return!1;for(let D=0;Dez(z,J),zJ=s;function JJ(z,J){let Z=J.sourcesTail;if(Z){let $=J.sources;while($){if($===z)return!0;if($===Z)break;$=$.nextSource}}return!1}function _(z,J){let Z=J.sourcesTail;if(Z?.source===z)return;let $=null,B=J.flags&Xz;if(B){if($=Z?Z.nextSource:J.sources,$?.source===z){J.sourcesTail=$;return}}let U=z.sinksTail;if(U?.sink===J&&(!B||JJ(U,J)))return;let D={source:z,sink:J,nextSource:$,prevSink:U,nextSink:null};if(J.sourcesTail=z.sinksTail=D,Z)Z.nextSource=D;else J.sources=D;if(U)U.nextSink=D;else z.sinks=D}function ZJ(z){let{source:J,nextSource:Z,nextSink:$,prevSink:B}=z;if($)$.prevSink=B;else J.sinksTail=B;if(B)B.nextSink=$;else J.sinks=$;if(!J.sinks){if(J.stop)J.stop(),J.stop=void 0;if("sources"in J&&J.sources){let U=J;U.sourcesTail=null,Uz(U),U.flags|=N}}return Z}function Uz(z){let J=z.sourcesTail,Z=J?J.nextSource:z.sources;while(Z)Z=ZJ(Z);if(J)J.nextSource=null;else z.sources=null}function w(z,J=N){let Z=z.flags;if("sinks"in z){if((Z&(N|r))>=J)return;if(z.flags=Z|J,"controller"in z&&z.controller)z.controller.abort(),z.controller=void 0;for(let $=z.sinks;$;$=$.nextSink)w($.sink,r)}else{if((Z&(N|r))>=J)return;let $=Z&(N|r);if(z.flags=Z&Xz|J,!$)Mz.push(z)}}function g(z,J){if(z.equals(z.value,J))return;z.value=J;for(let Z=z.sinks;Z;Z=Z.nextSink)w(Z.sink);if(x===0)F()}function jz(z,J){if(!z.cleanup)z.cleanup=J;else if(Array.isArray(z.cleanup))z.cleanup.push(J);else z.cleanup=[z.cleanup,J]}function Az(z){if(!z.cleanup)return;if(Array.isArray(z.cleanup))for(let J=0;J{if(J.signal.aborted)return;z.controller=void 0,u(()=>{if(z.error||!z.equals(B,z.value)){z.value=B,z.error=void 0;for(let U=z.sinks;U;U=U.nextSink)w(U.sink)}g(z.pendingNode,!1)})},(B)=>{if(J.signal.aborted)return;z.controller=void 0;let U=B instanceof Error?B:Error(String(B));u(()=>{if(!z.error||U.name!==z.error.name||U.message!==z.error.message){z.error=U;for(let D=z.sinks;D;D=D.nextSink)w(D.sink)}g(z.pendingNode,!1)})}),z.flags=S}function yz(z){Az(z);let J=G,Z=T;G=T=z,z.sourcesTail=null,z.flags=Xz;try{let $=z.fn();if(typeof $==="function")jz(z,$)}finally{G=J,T=Z,Uz(z),z.flags&=N|r}}function A(z){if(z.flags&r)for(let J=z.sources;J;J=J.nextSource){if("fn"in J.source)A(J.source);if(z.flags&N)break}if(z.flags&Xz)throw new Cz("controller"in z?a:("value"in z)?l:"Effect");if(z.flags&N)if("controller"in z)jJ(z);else if("value"in z)$J(z);else yz(z);else z.flags=S}var sz=1000;function F(){if(Ez)return;Ez=!0;let z,J=0;try{while(Mz.length>0){if(++J>sz){if(Mz.length=0,!z)z=[];z.push(new Fz(sz));break}let Z=Mz.slice();Mz.length=0;for(let $=0;$Az($);try{let U=z();if(typeof U==="function")jz($,U);return B}finally{if(T=Z,!J?.root&&Z)jz(Z,B)}}function WJ(z){let J=T;T=null;try{return z()}finally{T=J}}function v(z,J){return J?()=>{if(G){if(!z.sinks)z.stop=J();_(z,G)}}:()=>{if(G)_(z,G)}}function t(z,J){O(n,z,J?.guard);let Z={value:z,sinks:null,sinksTail:null,equals:J?.equals??p,guard:J?.guard};return{[Symbol.toStringTag]:n,get(){if(G)_(Z,G);return Z.value},set($){O(n,$,Z.guard),g(Z,$)},update($){h(n,$);let B=$(Z.value);O(n,B,Z.guard),g(Z,B)}}}function Rz(z){return f(z,n)}function fz(z,J){if(z.length!==J.length)return!1;for(let Z=0;Z`${z}${J++}`:Z?($)=>z($)||String(J++):()=>String(J++),Z]}function BJ(z,J,Z,$,B){let U={},D={},V={},P=[],q=!1,K=Math.min(z.length,J.length);for(let X=0;Xt(j,{equals:D})),P=()=>{let j=[];for(let H of $){let W=Z.get(H)?.get();if(W!==void 0)j.push(W)}return j},q={fn:P,value:z,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},K=(j)=>{let H=!1;for(let M in j.add){let R=j.add[M];O(`${L} item for key "${M}"`,R),Z.set(M,V(R)),H=!0}let W=!1;for(let M in j.change){W=!0;break}if(W)u(()=>{for(let M in j.change){let R=j.change[M];O(`${L} item for key "${M}"`,R);let y=Z.get(M);if(y)y.set(R)}});for(let M in j.remove){Z.delete(M);let R=$.indexOf(M);if(R!==-1)$.splice(R,1);H=!0}if(H)q.flags|=b;return j.changed},X=v(q,J?.watched);for(let j=0;jQ.get())))},at(j){X();let H=$[j];return H!==void 0?Z.get(H):void 0},keys(){return X(),$.values()},byKey(j){return X(),Z.get(j)},keyAt(j){return X(),$[j]},indexOfKey(j){return X(),$.indexOf(j)},add(j){let H=B(j);if(Z.has(H))throw new d(L,H,j);$.push(H),O(`${L} item for key "${H}"`,j),Z.set(H,V(j)),q.flags|=N|b;for(let W=q.sinks;W;W=W.nextSink)w(W.sink);if(x===0)F();return H},remove(j){let H=typeof j==="number"?$[j]:j;if(H===void 0)return;if(Z.delete(H)){let M=typeof j==="number"?j:$.indexOf(H);if(M>=0)$.splice(M,1);q.flags|=N|b;for(let R=q.sinks;R;R=R.nextSink)w(R.sink);if(x===0)F()}},replace(j,H){let W=Z.get(j);if(!W)return;if(O(`${L} item for key "${j}"`,H),D(Y(()=>W.get()),H))return;if(u(()=>{W.set(H),q.flags|=N;for(let M=q.sinks;M;M=M.nextSink)w(M.sink)}),x===0)F()},sort(j){let H=[];Y(()=>{for(let M of $){let R=Z.get(M)?.get();if(R!==void 0)H.push([M,R])}}),H.sort(c(j)?(M,R)=>j(M[1],R[1]):(M,R)=>String(M[1]).localeCompare(String(R[1])));let W=[];for(let[M]of H)W.push(M);if(!fz($,W)){$=W,q.flags|=N;for(let M=q.sinks;M;M=M.nextSink)w(M.sink);if(x===0)F()}},splice(j,H,...W){let M=$.length,R=j<0?Math.max(0,M+j):Math.min(j,M),y=Math.max(0,Math.min(H??Math.max(0,M-Math.max(0,R)),M-R)),Wz={},C={},I=!1;Y(()=>{for(let E=0;E$(()=>{if(w(Z),x===0)F()}):void 0);return{[Symbol.toStringTag]:l,get(){if(B(),A(Z),Z.error)throw Z.error;return qz(l,Z.value),Z.value}}}function kz(z){return f(z,l)}function Dz(z,J){if(h(a,z,Zz),J?.value!==void 0)O(a,J.value,J?.guard);let Z={value:!1,sinks:null,sinksTail:null,equals:p},$={fn:z,value:J?.value,sources:null,sourcesTail:null,sinks:null,sinksTail:null,flags:N,equals:J?.equals??p,controller:void 0,error:void 0,stop:void 0,pendingNode:Z},B=J?.watched,U=v($,B?()=>B(()=>{if(w($),x===0)F()}):void 0),D=v(Z);return{[Symbol.toStringTag]:a,get(){if(U(),A($),$.error)throw $.error;return qz(a,$.value),$.value},isPending(){return D(),$.pendingNode.value},abort(){$.controller?.abort(),$.controller=void 0,g($.pendingNode,!1)}}}function Gz(z){return f(z,a)}function _z(z,J){h(i,J);let Z=Zz(J),$=new Map,B=[],U=(j)=>{let H=Z?Dz(async(W,M)=>{let R=Y(()=>z.byKey(j));if(!R)return W;let y=R.get();if(y==null)return W;return J(y,M)}):Nz(()=>{let W=Y(()=>z.byKey(j));if(!W)return;let M=W.get();if(M==null)return;return J(M)});$.set(j,H)};function D(j){if(!fz(B,j)){let H=new Set(j);for(let W of B)if(!H.has(W))$.delete(W);for(let W of j)if(!$.has(W))U(W);B=j,q.flags|=b}}function V(){D(Array.from(z.keys()));let j=[];for(let H of B)try{let W=$.get(H)?.get();if(W!=null)j.push(W)}catch(W){if(!(W instanceof $z))throw W}return j}let q={fn:V,value:[],flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:(j,H)=>{if(j.length!==H.length)return!1;for(let W=0;Wz.keys()));for(let j of X)U(j);B=X;let Q={[Symbol.toStringTag]:i,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){if(G)_(q,G);K();for(let j of B){let H=$.get(j);if(H)yield H}},get length(){if(G)_(q,G);return K(),B.length},keys(){if(G)_(q,G);return K(),B.values()},get(){if(G)_(q,G);return K(),q.value},at(j){if(G)_(q,G);K();let H=B[j];return H!==void 0?$.get(H):void 0},byKey(j){if(G)_(q,G);return K(),$.get(j)},keyAt(j){if(G)_(q,G);return K(),B[j]},indexOfKey(j){if(G)_(q,G);return K(),B.indexOf(j)},deriveCollection(j){return _z(Q,j)}};return Q}function QJ(z,J){let Z=J?.value??[];if(Z.length)O(i,Z,Array.isArray);h(i,z,Bz);let $=new Map,B=[],U=new Map,[D,V]=pz(J?.keyConfig),P=(W)=>U.get(W)??(V?D(W):void 0),q=J?.createItem??((W)=>t(W,{equals:J?.itemEquals??s}));function K(){let W=[];for(let M of B)try{let R=$.get(M)?.get();if(R!=null)W.push(R)}catch(R){if(!(R instanceof $z))throw R}return W}let X={fn:K,value:Z,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:Sz,error:void 0};for(let W of Z){let M=D(W);$.set(M,q(W)),U.set(W,M),B.push(M)}X.value=Z,X.flags=N;let Q=(W)=>{let{add:M,change:R,remove:y}=W;if(!M?.length&&!R?.length&&!y?.length)return;let Wz=!1;u(()=>{if(M){let C=new Map;for(let I of M){let m=D(I);if($.has(m)||C.has(m))throw new d(i,m,I);C.set(m,I)}for(let[I,m]of C){if($.set(I,q(m)),U.set(m,I),!B.includes(I))B.push(I);Wz=!0}}if(R)for(let C of R){let I=P(C);if(!I)continue;let m=$.get(I);if(m&&Rz(m))U.delete(Y(()=>m.get())),m.set(C),U.set(C,I)}if(y)for(let C of y){let I=P(C);if(!I)continue;U.delete(C),$.delete(I);let m=B.indexOf(I);if(m!==-1)B.splice(m,1);Wz=!0}X.flags=N|(Wz?b:0);for(let C=X.sinks;C;C=C.nextSink)w(C.sink)})},j=v(X,()=>z(Q)),H={[Symbol.toStringTag]:i,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){j();for(let W of B){let M=$.get(W);if(M)yield M}},get length(){return j(),B.length},keys(){return j(),B.values()},get(){if(j(),X.sources){if(X.flags){let W=X.flags&b;if(X.value=Y(K),W){if(X.flags=N,A(X),X.error)throw X.error}else X.flags=S}}else if(A(X),X.error)throw X.error;return X.value},at(W){j();let M=B[W];return M!==void 0?$.get(M):void 0},byKey(W){return j(),$.get(W)},keyAt(W){return j(),B[W]},indexOfKey(W){return j(),B.indexOf(W)},deriveCollection(W){return _z(H,W)}};return H}function qJ(z){return f(z,i)}function MJ(z){h("Effect",z);let J={fn:z,flags:N,sources:null,sourcesTail:null,cleanup:null},Z=()=>{Az(J),J.fn=void 0,J.flags=S,J.sourcesTail=null,Uz(J)};if(T)jz(T,Z);return yz(J),tz(J),Z}function UJ(z,J){if(!T)throw new Iz("match");let Z=!Array.isArray(z),$=Z?[z]:z,{nil:B,stale:U}=J,D=Z?(Q)=>J.ok(Q[0]):(Q)=>J.ok(Q),V=Z&&J.err?(Q)=>J.err(Q[0]):J.err??console.error,P,q=!1,K=Array($.length);for(let Q=0;Q<$.length;Q++)try{K[Q]=$[Q].get()}catch(j){if(j instanceof $z){q=!0;continue}if(!P)P=[];P.push(j instanceof Error?j:Error(String(j)))}let X;try{if(q)X=B?.();else if(P)X=V(P);else if(U&&(Z?Gz($[0])&&$[0].isPending():$.some((Q)=>Gz(Q)&&Q.isPending())))X=U();else X=D(K)}catch(Q){X=V([Q instanceof Error?Q:Error(String(Q))])}if(typeof X==="function")return X;if(X instanceof Promise){let Q=T,j=new AbortController;jz(Q,()=>j.abort()),X.then((H)=>{if(!j.signal.aborted&&typeof H==="function")jz(Q,H)}).catch((H)=>{V([H instanceof Error?H:Error(String(H))])})}}function VJ(z,J){if(h(e,z,Bz),J?.value!==void 0)O(e,J.value,J?.guard);let Z={value:J?.value,sinks:null,sinksTail:null,equals:J?.equals??p,guard:J?.guard,stop:void 0};return{[Symbol.toStringTag]:e,get(){if(G){if(!Z.sinks)Z.stop=z(($)=>{O(e,$,Z.guard),g(Z,$)});_(Z,G)}return qz(e,Z.value),Z.value}}}function NJ(z){return f(z,e)}function DJ(z,J){let Z={},$={},B={},U=!1,D=Object.keys(z),V=Object.keys(J);for(let P of V)if(P in z){if(!s(z[P],J[P]))$[P]=J[P],U=!0}else Z[P]=J[P],U=!0;for(let P of D)if(!(P in J))B[P]=void 0,U=!0;return{add:Z,change:$,remove:B,changed:U}}function Oz(z,J){O(zz,z,k);let Z=new Map,$=(X,Q)=>{if(O(`${zz} for key "${X}"`,Q),Array.isArray(Q))Z.set(X,Vz(Q));else if(k(Q))Z.set(X,Oz(Q));else Z.set(X,t(Q))},B=(X)=>{if(Array.isArray(X))return"list";if(k(X))return"store";return"state"},U=(X)=>{if(Kz(X))return"list";if(Lz(X))return"store";return"state"},D=()=>{let X={};for(let[Q,j]of Z)X[Q]=j.get();return X},V={fn:D,value:z,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},P=(X)=>{let Q=!1;for(let H in X.add)$(H,X.add[H]),Q=!0;let j=!1;for(let H in X.change){j=!0;break}if(j)u(()=>{for(let H in X.change){let W=X.change[H];O(`${zz} for key "${H}"`,W);let M=Z.get(H);if(M)if(B(W)!==U(M))$(H,W),Q=!0;else M.set(W)}});for(let H in X.remove)Z.delete(H),Q=!0;if(Q)V.flags|=b;return X.changed},q=v(V,J?.watched);for(let X of Object.keys(z))$(X,z[X]);let K={[Symbol.toStringTag]:zz,[Symbol.isConcatSpreadable]:!1,*[Symbol.iterator](){q();for(let[X,Q]of Z)yield[X,Q]},keys(){return q(),Z.keys()},byKey(X){return Z.get(X)},get(){if(q(),V.sources){if(V.flags){let X=V.flags&b;if(V.value=Y(D),X){if(V.flags=N,A(V),V.error)throw V.error}else V.flags=S}}else if(A(V),V.error)throw V.error;return V.value},set(X){let Q=V.flags&N?Y(D):V.value,j=DJ(Q,X);if(P(j)){V.flags|=N;for(let H=V.sinks;H;H=H.nextSink)w(H.sink);if(x===0)F()}},update(X){K.set(X(Y(()=>K.get())))},add(X,Q){if(Z.has(X))throw new d(zz,X,Q);$(X,Q),V.flags|=N|b;for(let j=V.sinks;j;j=j.nextSink)w(j.sink);if(x===0)F();return X},remove(X){if(Z.delete(X)){V.flags|=N|b;for(let j=V.sinks;j;j=j.nextSink)w(j.sink);if(x===0)F()}}};return new Proxy(K,{get(X,Q){if(Q in X)return Reflect.get(X,Q);if(typeof Q!=="symbol")return X.byKey(Q)},set(X,Q){throw new Qz(String(Q),"assign to")},deleteProperty(X,Q){throw new Qz(String(Q),"delete")},defineProperty(X,Q){throw new Qz(String(Q),"define")},has(X,Q){if(Q in X)return!0;return X.byKey(String(Q))!==void 0},ownKeys(X){return Array.from(X.keys())},getOwnPropertyDescriptor(X,Q){if(Q in X)return Reflect.getOwnPropertyDescriptor(X,Q);if(typeof Q==="symbol")return;let j=X.byKey(String(Q));return j?{enumerable:!0,configurable:!0,writable:!0,value:j}:void 0}})}function Lz(z){return f(z,zz)}var GJ=new Set([n,l,a,e,Jz,L,i,zz]);function PJ(z,J){return Zz(z)?Dz(z,J):Nz(z,J)}function RJ(z){if(wz(z))return z;if(z==null)throw new Hz("createSignal",z);if(Zz(z))return Dz(z);if(c(z))return Nz(z);if(Array.isArray(z)&&z.every((J)=>J!=null))return Vz(z);if(k(z))return Oz(z);return t(z)}function KJ(z){if(oz(z))return z;if(z==null||c(z)||wz(z))throw new Hz("createMutableSignal",z);if(Array.isArray(z)&&z.every((J)=>J!=null))return Vz(z);if(k(z))return Oz(z);return t(z)}function OJ(z){return kz(z)||Gz(z)}function wz(z){return z!=null&&GJ.has(z[Symbol.toStringTag])}function oz(z){return Rz(z)||Lz(z)||Kz(z)}var gz=new WeakSet;function rz(z){if(wz(z))return!0;return z!==null&&typeof z==="object"&&"get"in z&&typeof z.get==="function"}function wJ(z,J){O(Jz,z,rz);let Z=z,$=J?.guard,B={fn:()=>Z.get(),value:void 0,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:J?.equals??p,error:void 0},U=()=>{if(G)_(B,G);if(A(B),B.error)throw B.error;return B.value},D=(P)=>{if(gz.has(B))throw Error("[Slot] Circular delegation detected in set()");gz.add(B);try{if(nz(Z))return void Z.set(P);if("set"in Z&&typeof Z.set==="function")O(Jz,P,$),Z.set(P);else throw new Yz(Jz)}finally{gz.delete(B)}},V=(P)=>{O(Jz,P,rz),Z=P,B.flags|=N;for(let q=B.sinks;q;q=q.nextSink)w(q.sink);if(x===0)F()};return{[Symbol.toStringTag]:Jz,configurable:!0,enumerable:!0,get:U,set:D,replace:V,current:()=>Z}}function nz(z){return f(z,Jz)}export{xz as valueString,Y as untrack,WJ as unown,UJ as match,Gz as isTask,Lz as isStore,Rz as isState,nz as isSlot,f as isSignalOfType,wz as isSignal,NJ as isSensor,k as isRecord,az as isObjectOfType,oz as isMutableSignal,kz as isMemo,Kz as isList,c as isFunction,zJ as isEqual,OJ as isComputed,qJ as isCollection,Zz as isAsyncFunction,Dz as createTask,Oz as createStore,t as createState,wJ as createSlot,RJ as createSignal,VJ as createSensor,XJ as createScope,KJ as createMutableSignal,Nz as createMemo,Vz as createList,MJ as createEffect,PJ as createComputed,QJ as createCollection,u as batch,$z as UnsetSignalValueError,Sz as SKIP_EQUALITY,Iz as RequiredOwnerError,Yz as ReadonlySignalError,mz as PromiseValueError,Pz as NullishSignalValueError,Qz as InvalidStoreMutationError,Hz as InvalidSignalValueError,Tz as InvalidCallbackError,Fz as EffectConvergenceError,d as DuplicateKeyError,p as DEFAULT_EQUALITY,s as DEEP_EQUALITY,Cz as CircularDependencyError}; diff --git a/index.ts b/index.ts index 74e5f75..7fc4e00 100644 --- a/index.ts +++ b/index.ts @@ -11,6 +11,7 @@ export { type Guard, InvalidCallbackError, InvalidSignalValueError, + InvalidStoreMutationError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, diff --git a/src/errors.ts b/src/errors.ts index 391120e..a8c95e0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -163,6 +163,29 @@ class DuplicateKeyError extends Error { } } +/** + * Error thrown when a Store property is assigned, deleted, or defined directly via the proxy. + */ +class InvalidStoreMutationError extends TypeError { + /** + * Constructs a new InvalidStoreMutationError. + * + * @param prop - The property name that was directly mutated. + * @param action - The kind of mutation attempted (`'assign to'`, `'delete'`, or `'define'`). + */ + constructor( + prop: string, + action: 'assign to' | 'delete' | 'define', + ) { + const guidance = + action === 'delete' + ? `use store.remove(${JSON.stringify(prop)})` + : `use store.${prop}.set(value), store.set(next), or store.add(key, value)` + super(`[Store] Cannot ${action} property "${prop}" directly — ${guidance}`) + this.name = 'InvalidStoreMutationError' + } +} + /* === Validation Functions === */ function validateSignalValue( @@ -206,6 +229,7 @@ export { InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, + InvalidStoreMutationError, ReadonlySignalError, RequiredOwnerError, DuplicateKeyError, diff --git a/src/nodes/store.ts b/src/nodes/store.ts index d68528e..1b6f590 100644 --- a/src/nodes/store.ts +++ b/src/nodes/store.ts @@ -1,4 +1,8 @@ -import { DuplicateKeyError, validateSignalValue } from '../errors' +import { + DuplicateKeyError, + InvalidStoreMutationError, + validateSignalValue, +} from '../errors' import { batch, batchDepth, @@ -136,6 +140,18 @@ function diffRecords(prev: T, next: T): DiffResult { * user.name.set('Bob'); // Only name subscribers react * console.log(user.get()); // { name: 'Bob', age: 30 } * ``` + * + * Direct property assignment, deletion, or `Object.defineProperty` through the + * proxy throws `InvalidStoreMutationError` — use `store.key.set(value)`, + * `store.set(next)`, `store.add(key, value)`, or `store.remove(key)` instead. + * Properties are typed as signals (not raw values) so destructuring preserves + * reactivity; this means proxy assignment is a compile-time error for typed + * stores. The runtime guard extends that protection to `any`-typed access, + * JS consumers, and `Object.assign`. See ADR-0017 for the full rationale. + * + * Note: a data key named like a base method (`get`, `set`, `keys`, `update`, + * `add`, `remove`, `byKey`) shadows the method via proxy access. Use + * `store.byKey(key)` to reach such a property. */ function createStore( value: T, @@ -357,6 +373,15 @@ function createStore( if (typeof prop !== 'symbol') return target.byKey(prop as keyof T & string) }, + set(_target, prop) { + throw new InvalidStoreMutationError(String(prop), 'assign to') + }, + deleteProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), 'delete') + }, + defineProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), 'define') + }, has(target, prop) { if (prop in target) return true return target.byKey(String(prop) as keyof T & string) !== undefined diff --git a/test/store.test.ts b/test/store.test.ts index f810285..0f6fc72 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -5,6 +5,7 @@ import { createScope, createState, createStore, + InvalidStoreMutationError, isList, isState, isStore, @@ -711,4 +712,71 @@ describe('Store', () => { expect(store.count.get()).toBe(5) }) }) + + describe('proxy write guard', () => { + test('direct property assignment throws InvalidStoreMutationError and leaves state intact', () => { + const store = createStore({ name: 'Alice' }) + expect(() => { + // @ts-expect-error deliberate misuse + store.name = 'Bob' + }).toThrow(InvalidStoreMutationError) + // The State signal is not shadowed + expect(isState(store.name)).toBe(true) + expect(store.name.get()).toBe('Alice') + // The reactive value is unchanged + expect(store.get()).toEqual({ name: 'Alice' }) + }) + + test('delete via proxy throws and leaves state intact', () => { + const store = createStore<{ name?: string }>({ name: 'Alice' }) + expect(() => { + delete store.name + }).toThrow(InvalidStoreMutationError) + expect(store.get()).toEqual({ name: 'Alice' }) + // remove() still works after the failed delete + store.remove('name') + expect(store.get()).toEqual({}) + }) + + test('Object.assign throws (routes through the set trap)', () => { + const store = createStore({ name: 'Alice' }) + expect(() => Object.assign(store, { name: 'Bob' })).toThrow( + InvalidStoreMutationError, + ) + expect(store.name.get()).toBe('Alice') + }) + + test('Object.defineProperty throws', () => { + const store = createStore<{ name: string; x?: number }>({ + name: 'Alice', + }) + expect(() => + Object.defineProperty(store, 'x', { value: 1 }), + ).toThrow(InvalidStoreMutationError) + expect(store.get()).toEqual({ name: 'Alice' }) + }) + + test('error messages name the correct alternative API', () => { + const store = createStore<{ name?: string }>({ name: 'Alice' }) + expect(() => { + // @ts-expect-error deliberate misuse + store.name = 'Bob' + }).toThrow( + '[Store] Cannot assign to property "name" directly — use store.name.set(value), store.set(next), or store.add(key, value)', + ) + expect(() => { + delete store.name + }).toThrow( + '[Store] Cannot delete property "name" directly — use store.remove("name")', + ) + }) + + test('symbol property assignment throws', () => { + const store = createStore<{ name: string }>({ name: 'Alice' }) + expect(() => { + // @ts-expect-error deliberate misuse with symbol key + store[Symbol.for('x')] = 1 + }).toThrow(InvalidStoreMutationError) + }) + }) }) diff --git a/types/index.d.ts b/types/index.d.ts index ff25a8d..ee31d81 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,7 +3,7 @@ * @version 1.3.4 * @author Esther Brunner */ -export { CircularDependencyError, DuplicateKeyError, type Guard, InvalidCallbackError, InvalidSignalValueError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors'; +export { CircularDependencyError, DuplicateKeyError, EffectConvergenceError, type Guard, InvalidCallbackError, InvalidSignalValueError, InvalidStoreMutationError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, RequiredOwnerError, UnsetSignalValueError, } from './src/errors'; /** @deprecated Use `DEEP_EQUALITY` instead. */ export { batch, type Cleanup, type ComputedOptions, createScope, DEEP_EQUALITY, DEFAULT_EQUALITY, type EffectCallback, isEqual, type MaybeCleanup, type MemoCallback, type ScopeOptions, type Signal, type SignalOptions, SKIP_EQUALITY, type TaskCallback, unown, untrack, } from './src/graph'; export { type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, createCollection, type DeriveCollectionCallback, isCollection, } from './src/nodes/collection'; diff --git a/types/src/errors.d.ts b/types/src/errors.d.ts index 05b650e..8c70553 100644 --- a/types/src/errors.d.ts +++ b/types/src/errors.d.ts @@ -18,6 +18,17 @@ declare class CircularDependencyError extends Error { */ constructor(where: string); } +/** + * Error thrown when queued effects keep re-triggering each other without settling. + */ +declare class EffectConvergenceError extends Error { + /** + * Constructs a new EffectConvergenceError. + * + * @param passes - The number of flush passes that ran without the graph settling. + */ + constructor(passes: number); +} /** * Error thrown when a signal value is null or undefined. */ @@ -97,8 +108,20 @@ declare class PromiseValueError extends TypeError { declare class DuplicateKeyError extends Error { constructor(where: string, key: string, value?: unknown); } +/** + * Error thrown when a Store property is assigned, deleted, or defined directly via the proxy. + */ +declare class InvalidStoreMutationError extends TypeError { + /** + * Constructs a new InvalidStoreMutationError. + * + * @param prop - The property name that was directly mutated. + * @param action - The kind of mutation attempted (`'assign to'`, `'delete'`, or `'define'`). + */ + constructor(prop: string, action: 'assign to' | 'delete' | 'define'); +} declare function validateSignalValue(where: string, value: unknown, guard?: Guard): asserts value is T; declare function validateReadValue(where: string, value: T | null | undefined): asserts value is T; declare function validateCallback(where: string, value: unknown): asserts value is (...args: unknown[]) => unknown; declare function validateCallback(where: string, value: unknown, guard: (value: unknown) => value is T): asserts value is T; -export { type Guard, CircularDependencyError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, ReadonlySignalError, RequiredOwnerError, DuplicateKeyError, PromiseValueError, validateSignalValue, validateReadValue, validateCallback, }; +export { type Guard, CircularDependencyError, EffectConvergenceError, NullishSignalValueError, InvalidSignalValueError, UnsetSignalValueError, InvalidCallbackError, InvalidStoreMutationError, ReadonlySignalError, RequiredOwnerError, DuplicateKeyError, PromiseValueError, validateSignalValue, validateReadValue, validateCallback, }; diff --git a/types/src/graph.d.ts b/types/src/graph.d.ts index e95f036..62a0809 100644 --- a/types/src/graph.d.ts +++ b/types/src/graph.d.ts @@ -189,6 +189,11 @@ declare function runCleanup(owner: OwnerNode): void; declare function runEffect(node: EffectNode): void; declare function refresh(node: SinkNode): void; declare function flush(): void; +/** + * Enqueues an effect that is still dirty after running (it wrote to its own + * dependencies) and, outside a batch, flushes until the graph converges. + */ +declare function scheduleEffect(node: EffectNode): void; /** * Batches multiple signal updates together. * Effects will not run until the batch completes. @@ -286,4 +291,4 @@ declare function createScope(fn: () => MaybeCleanup, options?: ScopeOptions): Cl */ declare function unown(fn: () => T): T; declare function makeSubscribe(node: SourceNode, onWatch?: () => Cleanup): () => void; -export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type ScopeOptions, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, DEEP_EQUALITY, isEqual, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, makeSubscribe, propagate, refresh, registerCleanup, runCleanup, runEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, unown, untrack, }; +export { type Cleanup, type ComputedOptions, type EffectCallback, type EffectNode, type MaybeCleanup, type MemoCallback, type MemoNode, type Scope, type ScopeOptions, type Signal, type SignalOptions, type SinkNode, type StateNode, type TaskCallback, type TaskNode, activeOwner, activeSink, batch, batchDepth, createScope, DEFAULT_EQUALITY, DEEP_EQUALITY, isEqual, SKIP_EQUALITY, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, link, makeSubscribe, propagate, refresh, registerCleanup, runCleanup, runEffect, scheduleEffect, setState, trimSources, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, TYPE_STATE, TYPE_SLOT, TYPE_STORE, TYPE_TASK, unlink, unown, untrack, }; diff --git a/types/src/nodes/effect.d.ts b/types/src/nodes/effect.d.ts index ecf1361..41fb27c 100644 --- a/types/src/nodes/effect.d.ts +++ b/types/src/nodes/effect.d.ts @@ -38,9 +38,16 @@ type SingleMatchHandlers = { * Effects run immediately upon creation and re-run when any tracked signal changes. * Effects are executed during the flush phase, after all updates have been batched. * + * An effect that writes to a signal it also depends on re-runs until the graph + * settles, so its last run always observes the final signal values. Effect graphs + * that never settle (e.g. an unconditional self-increment, or two effects writing + * each other's dependencies) throw `EffectConvergenceError` after a bounded number + * of flush passes instead of looping forever. + * * @since 0.1.0 * @param fn - The effect function that can track dependencies and register cleanup callbacks * @returns A cleanup function that can be called to dispose of the effect + * @throws EffectConvergenceError If effects keep re-triggering each other without settling * * @example * ```ts diff --git a/types/src/nodes/store.d.ts b/types/src/nodes/store.d.ts index 8d22e23..9f49896 100644 --- a/types/src/nodes/store.d.ts +++ b/types/src/nodes/store.d.ts @@ -49,6 +49,18 @@ type Store = BaseStore & { * user.name.set('Bob'); // Only name subscribers react * console.log(user.get()); // { name: 'Bob', age: 30 } * ``` + * + * Direct property assignment, deletion, or `Object.defineProperty` through the + * proxy throws `InvalidStoreMutationError` — use `store.key.set(value)`, + * `store.set(next)`, `store.add(key, value)`, or `store.remove(key)` instead. + * Properties are typed as signals (not raw values) so destructuring preserves + * reactivity; this means proxy assignment is a compile-time error for typed + * stores. The runtime guard extends that protection to `any`-typed access, + * JS consumers, and `Object.assign`. See ADR-0017 for the full rationale. + * + * Note: a data key named like a base method (`get`, `set`, `keys`, `update`, + * `add`, `remove`, `byKey`) shadows the method via proxy access. Use + * `store.byKey(key)` to reach such a property. */ declare function createStore(value: T, options?: StoreOptions): Store; /** From f4d8ae4369f762c5157f28067193348de827fb70 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 18:28:11 +0200 Subject: [PATCH 05/11] style: align Biome config with codebase conventions The repo uses single quotes, no semicolons, and no arrow-paren wrappers, but biome.json configured double quotes and relied on defaults that fought the existing style, producing ~53 format disagreements. - biome.json: quoteStyle single, semicolons asNeeded, arrowParentheses asNeeded; fix $schema URL to match installed Biome 2.4.6 - Apply biome check --write across 27 files (cosmetic only: line collapsing, quote/semicolon normalization; no semantic changes) - package.json: upgrade check/lint scripts from biome lint to biome check so CI can enforce formatting + import sorting alongside lint bun run check and bun run build both pass; bundle output byte-identical. --- .zed/settings.json | 16 +-- bench/reactivity.bench.ts | 3 +- biome.json | 4 +- package.json | 3 +- src/errors.ts | 5 +- src/graph.ts | 22 +---- src/nodes/collection.ts | 9 +- src/nodes/effect.ts | 24 +++-- src/nodes/list.ts | 36 ++----- src/nodes/slot.ts | 4 +- src/nodes/store.ts | 28 ++---- src/signal.ts | 3 +- test/benchmark.test.ts | 16 +-- test/collection.test.ts | 43 +++----- test/effect.test.ts | 9 +- test/equality.test.ts | 18 ++-- test/list.test.ts | 13 +-- test/memo.test.ts | 8 +- test/recipes.test.ts | 12 +-- test/regression-performance.test.ts | 8 +- test/slot.test.ts | 10 +- test/state.test.ts | 9 +- test/store.test.ts | 18 ++-- test/task.test.ts | 12 +-- test/unown.test.ts | 146 ++++++++++++++++++---------- test/util/core-entry.ts | 7 +- test/util/dependency-graph.ts | 4 +- tsconfig.build.json | 4 +- tsconfig.json | 4 +- 29 files changed, 215 insertions(+), 283 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index 1b9d380..c7c2815 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -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 + } + } + } } diff --git a/bench/reactivity.bench.ts b/bench/reactivity.bench.ts index 837a0ad..7cae98a 100644 --- a/bench/reactivity.bench.ts +++ b/bench/reactivity.bench.ts @@ -648,8 +648,7 @@ group('Collection: chain 5 derivations (100 items)', () => { bench('cause-effect', () => { const list = createList(Array.from({ length: 100 }, (_, i) => i + 1)) let col = list.deriveCollection((v: number) => v * 2) - for (let i = 1; i < 5; i++) - col = col.deriveCollection((v: number) => v + 1) + for (let i = 1; i < 5; i++) col = col.deriveCollection((v: number) => v + 1) col.get() }) }) diff --git a/biome.json b/biome.json index 88f22a6..f8c0fd6 100644 --- a/biome.json +++ b/biome.json @@ -21,7 +21,9 @@ }, "javascript": { "formatter": { - "quoteStyle": "double" + "quoteStyle": "single", + "semicolons": "asNeeded", + "arrowParentheses": "asNeeded" } }, "assist": { diff --git a/package.json b/package.json index faa1f55..104d168 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "bench": "bun run bench/reactivity.bench.ts", "test": "bun test --path-ignore-patterns=**/regression*", "regression": "bun test test/regression-performance.test.ts && bun test test/regression-bundle.test.ts", - "lint": "bunx biome lint --write" + "check": "bunx tsc --noEmit && bunx biome check . && bun run test && bun test test/regression-bundle.test.ts", + "lint": "bunx biome check --write" } } diff --git a/src/errors.ts b/src/errors.ts index a8c95e0..659a0d2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -173,10 +173,7 @@ class InvalidStoreMutationError extends TypeError { * @param prop - The property name that was directly mutated. * @param action - The kind of mutation attempted (`'assign to'`, `'delete'`, or `'define'`). */ - constructor( - prop: string, - action: 'assign to' | 'delete' | 'define', - ) { + constructor(prop: string, action: 'assign to' | 'delete' | 'define') { const guidance = action === 'delete' ? `use store.remove(${JSON.stringify(prop)})` diff --git a/src/graph.ts b/src/graph.ts index 64da080..12f2b25 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -236,12 +236,7 @@ const deepEqualInner = ( ): boolean => { if (Object.is(a, b)) return true if (typeof a !== typeof b) return false - if ( - a == null || - typeof a !== 'object' || - b == null || - typeof b !== 'object' - ) + if (a == null || typeof a !== 'object' || b == null || typeof b !== 'object') return false // Cycle guard: if `a` is already on the current recursion path, treat this @@ -406,8 +401,7 @@ function propagate(node: SinkNode, newFlag = FLAG_DIRTY): void { } // Propagate Check to sinks - for (let e = node.sinks; e; e = e.nextSink) - propagate(e.sink, FLAG_CHECK) + for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink, FLAG_CHECK) } else { if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) return @@ -520,8 +514,7 @@ function recomputeTask(node: TaskNode): void { if (node.error || !node.equals(next, node.value)) { node.value = next node.error = undefined - for (let e = node.sinks; e; e = e.nextSink) - propagate(e.sink) + for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) } setState(node.pendingNode, false) }) @@ -539,8 +532,7 @@ function recomputeTask(node: TaskNode): void { ) { // We don't clear old value on errors node.error = error - for (let e = node.sinks; e; e = e.nextSink) - propagate(e.sink) + for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) } setState(node.pendingNode, false) }) @@ -580,11 +572,7 @@ function refresh(node: SinkNode): void { if (node.flags & FLAG_RUNNING) { throw new CircularDependencyError( - 'controller' in node - ? TYPE_TASK - : 'value' in node - ? TYPE_MEMO - : 'Effect', + 'controller' in node ? TYPE_TASK : 'value' in node ? TYPE_MEMO : 'Effect', ) } diff --git a/src/nodes/collection.ts b/src/nodes/collection.ts index c364b26..00cc4f3 100644 --- a/src/nodes/collection.ts +++ b/src/nodes/collection.ts @@ -61,9 +61,7 @@ type Collection = Signal> = { byKey(key: string): S | undefined keyAt(index: number): string | undefined indexOfKey(key: string): number - deriveCollection( - callback: (sourceValue: T) => R, - ): Collection + deriveCollection(callback: (sourceValue: T) => R): Collection deriveCollection( callback: (sourceValue: T, abort: AbortSignal) => Promise, ): Collection @@ -154,10 +152,7 @@ function deriveCollection( const sourceValue = itemSignal.get() as U if (sourceValue == null) return prev as T return ( - callback as ( - sourceValue: U, - abort: AbortSignal, - ) => Promise + callback as (sourceValue: U, abort: AbortSignal) => Promise )(sourceValue, abort) }) : createMemo(() => { diff --git a/src/nodes/effect.ts b/src/nodes/effect.ts index 368811c..028179a 100644 --- a/src/nodes/effect.ts +++ b/src/nodes/effect.ts @@ -14,8 +14,8 @@ import { registerCleanup, runCleanup, runEffect, - scheduleEffect, type Signal, + scheduleEffect, trimSources, } from '../graph' import { isTask } from './task' @@ -32,9 +32,11 @@ type MaybePromise = T | Promise */ type MatchHandlers[]> = { /** Called when all signals have a value. Receives a tuple of resolved values. */ - ok: (values: { - [K in keyof T]: T[K] extends Signal ? V : never - }) => MaybePromise + ok: ( + values: { + [K in keyof T]: T[K] extends Signal ? V : never + }, + ) => MaybePromise /** Called when one or more signals hold an error. Defaults to `console.error`. */ err?: (errors: readonly Error[]) => MaybePromise /** Called when one or more signals are unset (pending). */ @@ -221,12 +223,14 @@ function match( const owner = activeOwner const controller = new AbortController() registerCleanup(owner, () => controller.abort()) - out.then(cleanup => { - if (!controller.signal.aborted && typeof cleanup === 'function') - registerCleanup(owner, cleanup) - }).catch(e => { - err([e instanceof Error ? e : new Error(String(e))]) - }) + out + .then(cleanup => { + if (!controller.signal.aborted && typeof cleanup === 'function') + registerCleanup(owner, cleanup) + }) + .catch(e => { + err([e instanceof Error ? e : new Error(String(e))]) + }) } } diff --git a/src/nodes/list.ts b/src/nodes/list.ts index 08a0ed4..68bc72b 100644 --- a/src/nodes/list.ts +++ b/src/nodes/list.ts @@ -99,9 +99,7 @@ type List = MutableSignal> = { replace(key: string, value: T): void sort(compareFn?: (a: T, b: T) => number): void splice(start: number, deleteCount?: number, ...items: T[]): T[] - deriveCollection( - callback: (sourceValue: T) => R, - ): Collection + deriveCollection(callback: (sourceValue: T) => R): Collection deriveCollection( callback: (sourceValue: T, abort: AbortSignal) => Promise, ): Collection @@ -281,9 +279,7 @@ function createList< const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig) const itemEquals = options?.itemEquals ?? DEEP_EQUALITY const itemFactory = (options?.createItem ?? - ((item: T) => createState(item, { equals: itemEquals }))) as ( - value: T, - ) => S + ((item: T) => createState(item, { equals: itemEquals }))) as (value: T) => S // --- Internal helpers --- @@ -334,10 +330,7 @@ function createList< batch(() => { for (const key in changes.change) { const val = changes.change[key] - validateSignalValue( - `${TYPE_LIST} item for key "${key}"`, - val, - ) + validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val) const signal = signals.get(key) if (signal) signal.set(val as T) } @@ -362,8 +355,7 @@ function createList< // --- Initialize --- for (let i = 0; i < value.length; i++) { const val = value[i] - if (val == null) - throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`) + if (val == null) throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`) let key = keys[i] if (!key) { key = generateKey(val) @@ -425,8 +417,7 @@ function createList< // Use cached value if clean, recompute if dirty. untrack prevents // buildValue's child .get() calls from leaking edges into whatever // effect is currently active (which would cause over-broad re-runs). - const prev = - node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value + const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value const changes = diffArrays( prev, next, @@ -476,8 +467,7 @@ function createList< add(value: T) { const key = generateKey(value) - if (signals.has(key)) - throw new DuplicateKeyError(TYPE_LIST, key, value) + if (signals.has(key)) throw new DuplicateKeyError(TYPE_LIST, key, value) keys.push(key) validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value) signals.set(key, itemFactory(value)) @@ -488,15 +478,12 @@ function createList< }, remove(keyOrIndex: string | number) { - const key = - typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex + const key = typeof keyOrIndex === 'number' ? keys[keyOrIndex] : keyOrIndex if (key === undefined) return const ok = signals.delete(key) if (ok) { const index = - typeof keyOrIndex === 'number' - ? keyOrIndex - : keys.indexOf(key) + typeof keyOrIndex === 'number' ? keyOrIndex : keys.indexOf(key) if (index >= 0) keys.splice(index, 1) node.flags |= FLAG_DIRTY | FLAG_RELINK for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) @@ -554,14 +541,11 @@ function createList< splice(start: number, deleteCount?: number, ...items: T[]) { const length = keys.length const actualStart = - start < 0 - ? Math.max(0, length + start) - : Math.min(start, length) + start < 0 ? Math.max(0, length + start) : Math.min(start, length) const actualDeleteCount = Math.max( 0, Math.min( - deleteCount ?? - Math.max(0, length - Math.max(0, actualStart)), + deleteCount ?? Math.max(0, length - Math.max(0, actualStart)), length - actualStart, ), ) diff --git a/src/nodes/slot.ts b/src/nodes/slot.ts index 9df9ae5..8709c37 100644 --- a/src/nodes/slot.ts +++ b/src/nodes/slot.ts @@ -147,9 +147,7 @@ function createSlot( } } - const replace = ( - next: Signal | SlotDescriptor, - ): void => { + const replace = (next: Signal | SlotDescriptor): void => { validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor) delegated = next diff --git a/src/nodes/store.ts b/src/nodes/store.ts index 1b6f590..548a4ea 100644 --- a/src/nodes/store.ts +++ b/src/nodes/store.ts @@ -44,10 +44,7 @@ type BaseStore = { readonly [Symbol.toStringTag]: 'Store' readonly [Symbol.isConcatSpreadable]: false [Symbol.iterator](): IterableIterator< - [ - string, - State | Store | List, - ] + [string, State | Store | List] > keys(): IterableIterator byKey( @@ -99,10 +96,7 @@ function diffRecords(prev: T, next: T): DiffResult { for (const key of nextKeys) { if (key in prev) { if ( - !DEEP_EQUALITY( - prev[key] as unknown & {}, - next[key] as unknown & {}, - ) + !DEEP_EQUALITY(prev[key] as unknown & {}, next[key] as unknown & {}) ) { change[key] = next[key] changed = true @@ -274,11 +268,7 @@ function createStore( for (const [key, signal] of signals) { yield [key, signal] as [ string, - ( - | State - | Store - | List - ), + State | Store | List, ] } }, @@ -289,8 +279,7 @@ function createStore( }, byKey(key: K) { - return signals.get(key) as T[K] extends readonly (infer U extends - {})[] + return signals.get(key) as T[K] extends readonly (infer U extends {})[] ? List : T[K] extends UnknownRecord ? Store @@ -331,8 +320,7 @@ function createStore( // Use cached value if clean, recompute if dirty. untrack prevents // buildValue's child .get() calls from leaking edges into whatever // effect is currently active (which would cause over-broad re-runs). - const prev = - node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value + const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value const changes = diffRecords(prev, next) if (applyChanges(changes)) { @@ -347,8 +335,7 @@ function createStore( }, add(key: K, value: T[K]) { - if (signals.has(key)) - throw new DuplicateKeyError(TYPE_STORE, key, value) + if (signals.has(key)) throw new DuplicateKeyError(TYPE_STORE, key, value) addSignal(key, value) node.flags |= FLAG_DIRTY | FLAG_RELINK for (let e = node.sinks; e; e = e.nextSink) propagate(e.sink) @@ -390,8 +377,7 @@ function createStore( return Array.from(target.keys()) }, getOwnPropertyDescriptor(target, prop) { - if (prop in target) - return Reflect.getOwnPropertyDescriptor(target, prop) + if (prop in target) return Reflect.getOwnPropertyDescriptor(target, prop) if (typeof prop === 'symbol') return undefined const signal = target.byKey(String(prop) as keyof T & string) return signal diff --git a/src/signal.ts b/src/signal.ts index 0d7a37e..21607e0 100644 --- a/src/signal.ts +++ b/src/signal.ts @@ -89,8 +89,7 @@ function createSignal(value: unknown): unknown { if (value == null) throw new InvalidSignalValueError('createSignal', value) if (isAsyncFunction(value)) return createTask(value as TaskCallback) - if (isFunction(value)) - return createMemo(value as MemoCallback) + if (isFunction(value)) return createMemo(value as MemoCallback) if (Array.isArray(value) && value.every(item => item != null)) return createList(value as (unknown & {})[]) if (isRecord(value)) return createStore(value) diff --git a/test/benchmark.test.ts b/test/benchmark.test.ts index 5a16f39..582d6f5 100644 --- a/test/benchmark.test.ts +++ b/test/benchmark.test.ts @@ -291,9 +291,7 @@ for (const framework of [v18]) { }) test('mux', async () => { - const heads = new Array(100) - .fill(null) - .map(_ => framework.signal(0)) + const heads = new Array(100).fill(null).map(_ => framework.signal(0)) const mux = framework.computed(() => Object.fromEntries(heads.map(h => h.read()).entries()), ) @@ -451,9 +449,7 @@ for (const framework of [v18]) { framework.withBuild(() => { const A = framework.signal(0) const B = framework.signal(0) - const C = framework.computed( - () => (A.read() % 2) + (B.read() % 2), - ) + const C = framework.computed(() => (A.read() % 2) + (B.read() % 2)) const D = framework.computed(() => numbers.map(i => ({ x: i + (A.read() % 2) - (B.read() % 2), @@ -547,12 +543,8 @@ for (const framework of [v18]) { const m: CellxLayer = layer const s = { prop1: framework.computed(() => m.prop2.read()), - prop2: framework.computed( - () => m.prop1.read() - m.prop3.read(), - ), - prop3: framework.computed( - () => m.prop2.read() + m.prop4.read(), - ), + prop2: framework.computed(() => m.prop1.read() - m.prop3.read()), + prop3: framework.computed(() => m.prop2.read() + m.prop4.read()), prop4: framework.computed(() => m.prop3.read()), } diff --git a/test/collection.test.ts b/test/collection.test.ts index 440ad67..7101eb3 100644 --- a/test/collection.test.ts +++ b/test/collection.test.ts @@ -113,9 +113,9 @@ describe('Collection', () => { expect(isCollection(42)).toBe(false) expect(isCollection(null)).toBe(false) expect(isCollection({})).toBe(false) - expect( - isList(createCollection(() => () => {}, { value: [1] })), - ).toBe(false) + expect(isList(createCollection(() => () => {}, { value: [1] }))).toBe( + false, + ) }) }) @@ -175,9 +175,7 @@ describe('Collection', () => { describe('applyChanges', () => { test('should add items', () => { - let apply: - | ((changes: CollectionChanges) => void) - | undefined + let apply: ((changes: CollectionChanges) => void) | undefined const col = createCollection(applyChanges => { apply = applyChanges return () => {} @@ -204,9 +202,7 @@ describe('Collection', () => { test('should change item values', () => { let apply: - | (( - changes: CollectionChanges<{ id: string; val: number }>, - ) => void) + | ((changes: CollectionChanges<{ id: string; val: number }>) => void) | undefined const col = createCollection( applyChanges => { @@ -239,9 +235,7 @@ describe('Collection', () => { test('should remove items', () => { let apply: - | (( - changes: CollectionChanges<{ id: string; v: number }>, - ) => void) + | ((changes: CollectionChanges<{ id: string; v: number }>) => void) | undefined const col = createCollection( applyChanges => { @@ -286,9 +280,7 @@ describe('Collection', () => { test('should handle mixed add/change/remove', () => { let apply: - | (( - changes: CollectionChanges<{ id: string; v: number }>, - ) => void) + | ((changes: CollectionChanges<{ id: string; v: number }>) => void) | undefined const col = createCollection( applyChanges => { @@ -328,9 +320,7 @@ describe('Collection', () => { }) test('should skip when no changes provided', () => { - let apply: - | ((changes: CollectionChanges) => void) - | undefined + let apply: ((changes: CollectionChanges) => void) | undefined const col = createCollection( applyChanges => { apply = applyChanges @@ -358,9 +348,7 @@ describe('Collection', () => { }) test('should trigger effects on structural changes', () => { - let apply: - | ((changes: CollectionChanges) => void) - | undefined + let apply: ((changes: CollectionChanges) => void) | undefined const col = createCollection(applyChanges => { apply = applyChanges return () => {} @@ -386,9 +374,7 @@ describe('Collection', () => { }) test('should batch multiple calls', () => { - let apply: - | ((changes: CollectionChanges) => void) - | undefined + let apply: ((changes: CollectionChanges) => void) | undefined const col = createCollection(applyChanges => { apply = applyChanges return () => {} @@ -622,9 +608,7 @@ describe('Collection', () => { // once during setup (transient leak, mode b). test('applyChanges({ change }) inside an effect does not transiently re-run', () => { type Item = { id: string; v: number } - let apply: - | ((changes: CollectionChanges) => void) - | undefined + let apply: ((changes: CollectionChanges) => void) | undefined const col = createCollection( applyChanges => { apply = applyChanges @@ -1030,9 +1014,6 @@ test('Type Inference for custom createItem', () => { ? true : false type _Test = Expect< - Equal< - typeof byKey, - ReturnType> | undefined - > + Equal> | undefined> > }) diff --git a/test/effect.test.ts b/test/effect.test.ts index 1759f63..f915a45 100644 --- a/test/effect.test.ts +++ b/test/effect.test.ts @@ -536,9 +536,7 @@ describe('createEffect', () => { '[Effect] Callback null is invalid', ) // @ts-expect-error - Testing invalid input - expect(() => createEffect(42)).toThrow( - '[Effect] Callback 42 is invalid', - ) + expect(() => createEffect(42)).toThrow('[Effect] Callback 42 is invalid') }) }) }) @@ -1277,10 +1275,7 @@ describe('match', () => { ok: () => new Promise((_resolve, reject) => { // Reject asynchronously, after disposal. - setTimeout( - () => reject(new Error('late reject')), - 10, - ) + setTimeout(() => reject(new Error('late reject')), 10) }), err: e => { errCount++ diff --git a/test/equality.test.ts b/test/equality.test.ts index bf9543f..ac57b21 100644 --- a/test/equality.test.ts +++ b/test/equality.test.ts @@ -131,9 +131,9 @@ describe('DEEP_EQUALITY', () => { }) test('differ in length', () => { - expect( - DEEP_EQUALITY([1, 2] as number[], [1, 2, 3] as number[]), - ).toBe(false) + expect(DEEP_EQUALITY([1, 2] as number[], [1, 2, 3] as number[])).toBe( + false, + ) }) test('differ in one element', () => { @@ -149,15 +149,15 @@ describe('DEEP_EQUALITY', () => { }) test('array of equal objects', () => { - expect( - DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 2 }]), - ).toBe(true) + expect(DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 2 }])).toBe( + true, + ) }) test('array of objects differing in one element', () => { - expect( - DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 9 }]), - ).toBe(false) + expect(DEEP_EQUALITY([{ x: 1 }, { x: 2 }], [{ x: 1 }, { x: 9 }])).toBe( + false, + ) }) }) diff --git a/test/list.test.ts b/test/list.test.ts index 9741630..b5627a8 100644 --- a/test/list.test.ts +++ b/test/list.test.ts @@ -199,9 +199,7 @@ describe('List', () => { const list = createList([{ id: 'a', val: 1 }], { keyConfig: item => item.id, }) - expect(() => list.add({ id: 'a', val: 2 })).toThrow( - 'already exists', - ) + expect(() => list.add({ id: 'a', val: 2 })).toThrow('already exists') }) test('DuplicateKeyError message includes [List] prefix and key', () => { @@ -603,9 +601,7 @@ describe('List', () => { test('computed signals should react to list changes', () => { const list = createList([1, 2, 3]) - const sum = createMemo(() => - list.get().reduce((acc, n) => acc + n, 0), - ) + const sum = createMemo(() => list.get().reduce((acc, n) => acc + n, 0)) expect(sum.get()).toBe(6) list.add(4) @@ -1141,9 +1137,6 @@ test('Type Inference for custom createItem', () => { ? true : false type _Test = Expect< - Equal< - typeof byKey, - ReturnType> | undefined - > + Equal> | undefined> > }) diff --git a/test/memo.test.ts b/test/memo.test.ts index 05bfb37..54fb34d 100644 --- a/test/memo.test.ts +++ b/test/memo.test.ts @@ -362,13 +362,9 @@ describe('Memo', () => { describe('Input Validation', () => { test('should throw InvalidCallbackError for non-function callback', () => { // @ts-expect-error - Testing invalid input - expect(() => createMemo(null)).toThrow( - '[Memo] Callback null is invalid', - ) + expect(() => createMemo(null)).toThrow('[Memo] Callback null is invalid') // @ts-expect-error - Testing invalid input - expect(() => createMemo(42)).toThrow( - '[Memo] Callback 42 is invalid', - ) + expect(() => createMemo(42)).toThrow('[Memo] Callback 42 is invalid') // @ts-expect-error - Testing invalid input expect(() => createMemo('str')).toThrow( '[Memo] Callback "str" is invalid', diff --git a/test/recipes.test.ts b/test/recipes.test.ts index 30fb136..ebafff1 100644 --- a/test/recipes.test.ts +++ b/test/recipes.test.ts @@ -46,8 +46,7 @@ describe('Recipes', () => { const wizardState = createMemo(() => { const step = currentStep.get() - const canProceed = - step === 1 ? isStep1Valid.get() : isStep2Valid.get() + const canProceed = step === 1 ? isStep1Valid.get() : isStep2Valid.get() const isComplete = step === totalSteps && canProceed return { @@ -59,10 +58,7 @@ describe('Recipes', () => { }) function nextStep() { - if ( - wizardState.get().canProceed && - currentStep.get() < totalSteps - ) { + if (wizardState.get().canProceed && currentStep.get() < totalSteps) { currentStep.update(s => s + 1) } } @@ -163,9 +159,7 @@ describe('Recipes', () => { applyComplexServerSync({ removed: ['w2'], modified: [{ id: 'w1', newMembers: ['Alice', 'Bob', 'Dave'] }], - added: [ - { id: 'w3', name: 'Marketing', members: ['Eve'], active: true }, - ], + added: [{ id: 'w3', name: 'Marketing', members: ['Eve'], active: true }], }) expect(totalCount.get()).toBe(4) diff --git a/test/regression-performance.test.ts b/test/regression-performance.test.ts index 3ccb043..040167d 100644 --- a/test/regression-performance.test.ts +++ b/test/regression-performance.test.ts @@ -109,9 +109,7 @@ describe('Performance — primitive nodes', () => { const branches = Array.from({ length: 5 }, () => f.createMemo(() => head.get() + 1), ) - const sum = f.createMemo(() => - branches.reduce((a, b) => a + b.get(), 0), - ) + const sum = f.createMemo(() => branches.reduce((a, b) => a + b.get(), 0)) f.createEffect(() => { sum.get() }) @@ -233,9 +231,7 @@ describe('Performance — composite nodes', () => { test('derived collection item update (2000 iterations)', () => { const setup = (f: typeof current) => () => { - const list = f.createList( - Array.from({ length: 5 }, (_, i) => i), - ) + const list = f.createList(Array.from({ length: 5 }, (_, i) => i)) const derived = list.deriveCollection((v: number) => v * 2) // biome-ignore lint/style/noNonNullAssertion: list is pre-populated const firstKey = list.keyAt(0)! diff --git a/test/slot.test.ts b/test/slot.test.ts index c57f665..3b074e3 100644 --- a/test/slot.test.ts +++ b/test/slot.test.ts @@ -254,20 +254,20 @@ describe('Slot', () => { get: () => state.get() * 2, set: (val: number) => state.set(val / 2), }) - + expect(slot.get()).toBe(2) - + let runs = 0 createEffect(() => { slot.get() runs++ }) expect(runs).toBe(1) - + state.set(5) expect(runs).toBe(2) expect(slot.get()).toBe(10) - + slot.set(100) expect(state.get()).toBe(50) expect(runs).toBe(3) @@ -326,7 +326,7 @@ describe('Slot', () => { const slot = createSlot(state, { // Only compare by x — changes to y should not propagate. // First call receives undefined prev, so guard against that. - equals: (a, b) => b == null ? false : a.x === b.x, + equals: (a, b) => (b == null ? false : a.x === b.x), }) let runs = 0 let seenX = 0 diff --git a/test/state.test.ts b/test/state.test.ts index 9529733..d23d866 100644 --- a/test/state.test.ts +++ b/test/state.test.ts @@ -97,10 +97,7 @@ describe('State', () => { describe('options.equals', () => { test('should use custom equality function to skip updates', () => { - const state = createState( - { x: 1 }, - { equals: (a, b) => a.x === b.x }, - ) + const state = createState({ x: 1 }, { equals: (a, b) => a.x === b.x }) let effectCount = 0 createEffect(() => { state.get() @@ -142,9 +139,7 @@ describe('State', () => { const state = createState(1, { guard: (v): v is number => typeof v === 'number' && v > 0, }) - expect(() => state.set(0)).toThrow( - '[State] Signal value 0 is invalid', - ) + expect(() => state.set(0)).toThrow('[State] Signal value 0 is invalid') expect(state.get()).toBe(1) // unchanged }) diff --git a/test/store.test.ts b/test/store.test.ts index 0f6fc72..7bed099 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -385,13 +385,11 @@ describe('Store', () => { test('should maintain property key ordering', () => { const config = createStore({ alpha: 1, beta: 2, gamma: 3 }) const entries = [...config] - expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual( - [ - ['alpha', 1], - ['beta', 2], - ['gamma', 3], - ], - ) + expect(entries.map(([key, signal]) => [key, signal.get()])).toEqual([ + ['alpha', 1], + ['beta', 2], + ['gamma', 3], + ]) }) }) @@ -750,9 +748,9 @@ describe('Store', () => { const store = createStore<{ name: string; x?: number }>({ name: 'Alice', }) - expect(() => - Object.defineProperty(store, 'x', { value: 1 }), - ).toThrow(InvalidStoreMutationError) + expect(() => Object.defineProperty(store, 'x', { value: 1 })).toThrow( + InvalidStoreMutationError, + ) expect(store.get()).toEqual({ name: 'Alice' }) }) diff --git a/test/task.test.ts b/test/task.test.ts index 915167d..fc9ee95 100644 --- a/test/task.test.ts +++ b/test/task.test.ts @@ -462,13 +462,9 @@ describe('Task', () => { test('should throw InvalidCallbackError for non-function callback', () => { // @ts-expect-error - Testing invalid input - expect(() => createTask(null)).toThrow( - '[Task] Callback null is invalid', - ) + expect(() => createTask(null)).toThrow('[Task] Callback null is invalid') // @ts-expect-error - Testing invalid input - expect(() => createTask(42)).toThrow( - '[Task] Callback 42 is invalid', - ) + expect(() => createTask(42)).toThrow('[Task] Callback 42 is invalid') }) test('should throw NullishSignalValueError for null initial value', () => { @@ -519,9 +515,7 @@ describe('Task', () => { } expect(caught).toBeInstanceOf(Error) expect((caught as Error).message).toBe('sync boom') - expect((caught as Error).message).not.toContain( - 'Circular dependency', - ) + expect((caught as Error).message).not.toContain('Circular dependency') }) }) diff --git a/test/unown.test.ts b/test/unown.test.ts index 6c408f6..6315ec7 100644 --- a/test/unown.test.ts +++ b/test/unown.test.ts @@ -1,19 +1,18 @@ import { describe, expect, test } from 'bun:test' -import { - createEffect, - createScope, - createState, - unown, -} from '../index.ts' +import { createEffect, createScope, createState, unown } from '../index.ts' describe('createScope with root option', () => { - test('root scope is not registered on enclosing scope', () => { let rootCleanupRan = false const outerDispose = createScope(() => { - createScope(() => { - return () => { rootCleanupRan = true } - }, { root: true }) + createScope( + () => { + return () => { + rootCleanupRan = true + } + }, + { root: true }, + ) }) outerDispose() expect(rootCleanupRan).toBe(false) @@ -26,9 +25,14 @@ describe('createScope with root option', () => { const outerDispose = createScope(() => { createEffect((): undefined => { trigger.get() - createScope(() => { - return () => { rootCleanupRuns++ } - }, { root: true }) + createScope( + () => { + return () => { + rootCleanupRuns++ + } + }, + { root: true }, + ) }) }) @@ -47,12 +51,17 @@ describe('createScope with root option', () => { let componentCleanupRuns = 0 const connectComponent = () => - createScope(() => { - createEffect((): undefined => { - componentEffectRuns++ - }) - return () => { componentCleanupRuns++ } - }, { root: true }) + createScope( + () => { + createEffect((): undefined => { + componentEffectRuns++ + }) + return () => { + componentCleanupRuns++ + } + }, + { root: true }, + ) const outerDispose = createScope(() => { createEffect((): undefined => { @@ -73,9 +82,14 @@ describe('createScope with root option', () => { test('dispose returned from root scope still works', () => { let cleanupRan = false - const dispose = createScope(() => { - return () => { cleanupRan = true } - }, { root: true }) + const dispose = createScope( + () => { + return () => { + cleanupRan = true + } + }, + { root: true }, + ) expect(cleanupRan).toBe(false) dispose() expect(cleanupRan).toBe(true) @@ -85,12 +99,15 @@ describe('createScope with root option', () => { const source = createState('a') let effectRuns = 0 - const dispose = createScope(() => { - createEffect((): undefined => { - source.get() - effectRuns++ - }) - }, { root: true }) + const dispose = createScope( + () => { + createEffect((): undefined => { + source.get() + effectRuns++ + }) + }, + { root: true }, + ) expect(effectRuns).toBe(1) source.set('b') @@ -105,22 +122,27 @@ describe('createScope with root option', () => { let postCleanupRan = false const outerDispose = createScope(() => { try { - createScope(() => { throw new Error('boom') }, { root: true }) + createScope( + () => { + throw new Error('boom') + }, + { root: true }, + ) } catch { // swallow } createScope(() => { - return () => { postCleanupRan = true } + return () => { + postCleanupRan = true + } }) }) outerDispose() expect(postCleanupRan).toBe(true) }) - }) describe('unown', () => { - test('should return the value of the callback', () => { const result = unown(() => 42) expect(result).toBe(42) @@ -128,7 +150,9 @@ describe('unown', () => { test('should run the callback immediately and synchronously', () => { let ran = false - unown(() => { ran = true }) + unown(() => { + ran = true + }) expect(ran).toBe(true) }) @@ -137,7 +161,9 @@ describe('unown', () => { const outerDispose = createScope(() => { unown(() => { createScope(() => { - return () => { innerCleanupRan = true } + return () => { + innerCleanupRan = true + } }) }) }) @@ -154,7 +180,9 @@ describe('unown', () => { trigger.get() unown(() => { createScope(() => { - return () => { innerCleanupRuns++ } + return () => { + innerCleanupRuns++ + } }) }) }) @@ -174,14 +202,17 @@ describe('unown', () => { let componentEffectRuns = 0 let componentCleanupRuns = 0 - const connectComponent = () => unown(() => - createScope(() => { - createEffect((): undefined => { - componentEffectRuns++ - }) - return () => { componentCleanupRuns++ } - }) - ) + const connectComponent = () => + unown(() => + createScope(() => { + createEffect((): undefined => { + componentEffectRuns++ + }) + return () => { + componentCleanupRuns++ + } + }), + ) const outerDispose = createScope(() => { createEffect((): undefined => { @@ -210,7 +241,7 @@ describe('unown', () => { source.get() effectRuns++ }) - }) + }), ) expect(effectRuns).toBe(1) @@ -226,8 +257,10 @@ describe('unown', () => { let cleanupRan = false const dispose = unown(() => createScope(() => { - return () => { cleanupRan = true } - }) + return () => { + cleanupRan = true + } + }), ) expect(cleanupRan).toBe(false) dispose() @@ -240,7 +273,9 @@ describe('unown', () => { unown(() => { unown(() => { createScope(() => { - return () => { innerCleanupRan = true } + return () => { + innerCleanupRan = true + } }) }) }) @@ -252,9 +287,13 @@ describe('unown', () => { test('restores the active owner after the callback completes', () => { let postCleanupRan = false const outerDispose = createScope(() => { - unown(() => { /* some unowned work */ }) + unown(() => { + /* some unowned work */ + }) createScope(() => { - return () => { postCleanupRan = true } + return () => { + postCleanupRan = true + } }) }) outerDispose() @@ -265,12 +304,16 @@ describe('unown', () => { let postCleanupRan = false const outerDispose = createScope(() => { try { - unown(() => { throw new Error('boom') }) + unown(() => { + throw new Error('boom') + }) } catch { // swallow } createScope(() => { - return () => { postCleanupRan = true } + return () => { + postCleanupRan = true + } }) }) outerDispose() @@ -288,5 +331,4 @@ describe('unown', () => { expect(ran).toBe(true) expect(typeof dispose).toBe('function') }) - }) diff --git a/test/util/core-entry.ts b/test/util/core-entry.ts index a5c032d..9b93d87 100644 --- a/test/util/core-entry.ts +++ b/test/util/core-entry.ts @@ -4,7 +4,12 @@ * path a real consumer uses — and actually wires the four core signal types * together so the bundler can't eliminate code a real usage would retain. */ -import { createEffect, createMemo, createState, createTask } from '../../index.ts' +import { + createEffect, + createMemo, + createState, + createTask, +} from '../../index.ts' const count = createState(0) const doubled = createMemo(() => count.get() * 2) diff --git a/test/util/dependency-graph.ts b/test/util/dependency-graph.ts index 4c42a0a..93068a7 100644 --- a/test/util/dependency-graph.ts +++ b/test/util/dependency-graph.ts @@ -24,9 +24,7 @@ export function makeGraph( const { width, totalLayers, staticFraction, nSources } = config return framework.withBuild(() => { - const sources = new Array(width) - .fill(0) - .map((_, i) => framework.signal(i)) + const sources = new Array(width).fill(0).map((_, i) => framework.signal(i)) const rows = makeDependentRows( sources, totalLayers - 1, diff --git a/tsconfig.build.json b/tsconfig.build.json index 55b4f8b..7f0ab99 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,8 +4,8 @@ "noEmit": false, "declaration": true, "declarationDir": "./types", - "emitDeclarationOnly": true, + "emitDeclarationOnly": true }, "include": ["./index.ts", "./src/**/*.ts"], - "exclude": ["node_modules", "types", "test", "archive"], + "exclude": ["node_modules", "types", "test", "archive"] } diff --git a/tsconfig.json b/tsconfig.json index 03f3826..81a6c8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,8 +37,8 @@ /* Performance */ "incremental": true, - "tsBuildInfoFile": "./.tsbuildinfo", + "tsBuildInfoFile": "./.tsbuildinfo" }, "include": ["./**/*.ts"], - "exclude": ["node_modules", "types", "index.js"], + "exclude": ["node_modules", "types", "index.js"] } From 9a3f18501d0f0f98f5a6991f35a9048ec2d8944f Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 18:28:27 +0200 Subject: [PATCH 06/11] ci: add CI workflow and gate npm publish on test suite The repo had no CI; the only workflow (npm-publish.yml) published to npm on GitHub release without running any tests, so a broken branch could ship a release with provenance attached. - ci.yml: two jobs on push/PR to main and next - test (required): typecheck, biome check, unit tests, bundle-size regression, build - performance (continue-on-error): timing regression, informational only since shared runners are too noisy for assertions - npm-publish.yml: run bun run check (typecheck + biome check + tests + bundle regression) before Build package Both jobs use frozen-lockfile installs so PRs can't silently change dependencies. After merge, mark the test job as a required status check in GitHub branch protection for main and next. --- .github/workflows/ci.yml | 55 +++++++++++++++++++++++++++++++ .github/workflows/npm-publish.yml | 3 ++ 2 files changed, 58 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9052950 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main, next] + pull_request: + branches: [main, next] + +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: + 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 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 7e79784..91bdf9c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -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 From c8ab31c550b1d958a95c478670f8dc9327af70f5 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 18:50:25 +0200 Subject: [PATCH 07/11] chore: update biome and fix formatting --- biome.json | 4 ++-- bun.lock | 22 +++++++++++----------- package.json | 2 +- src/errors.ts | 16 ++++++++-------- src/graph.ts | 40 ++++++++++++++++++++-------------------- src/nodes/collection.ts | 6 +++--- src/nodes/effect.ts | 6 +++--- src/nodes/list.ts | 10 +++++----- src/signal.ts | 6 +++--- src/util.ts | 6 +++--- 10 files changed, 59 insertions(+), 59 deletions(-) diff --git a/biome.json b/biome.json index f8c0fd6..4664dff 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -16,7 +16,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "preset": "recommended" } }, "javascript": { diff --git a/bun.lock b/bun.lock index 5ed9e48..1d5f1fb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@zeix/cause-effect", "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "^2.5.3", "@types/bun": "^1.3.14", "@zeix/cause-effect-stable": "npm:@zeix/cause-effect", "mitata": "^1.0.34", @@ -18,29 +18,29 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@22.10.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww=="], - "@zeix/cause-effect-stable": ["@zeix/cause-effect@1.3.3", "", { "peerDependencies": { "typescript": ">=5.8.0" } }, "sha512-grXYA4hw1mQgIcQsl3qLwv/MFjLgfuGt7/0FPAA9U2sfy9BG3a1yjaSTKcySnVgimCNmzHWrwAKUJlxIg5ZS0A=="], + "@zeix/cause-effect-stable": ["@zeix/cause-effect@1.3.4", "", { "peerDependencies": { "typescript": ">=5.8.0" } }, "sha512-vQQ7YIV8TGwGy2xXsXuOyrt+XtnK2xbT4WdGrpa7KcebJRm+AFEF8a/i4ICg1vR1TQf8M3QG8s3/XTwGI0VR8Q=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index 104d168..4a471c9 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "module": "index.ts", "types": "types/index.d.ts", "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "^2.5.3", "@types/bun": "^1.3.14", "@zeix/cause-effect-stable": "npm:@zeix/cause-effect", "mitata": "^1.0.34", diff --git a/src/errors.ts b/src/errors.ts index 659a0d2..d67ae29 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -219,19 +219,19 @@ function validateCallback( } export { - type Guard, CircularDependencyError, + DuplicateKeyError, EffectConvergenceError, - NullishSignalValueError, - InvalidSignalValueError, - UnsetSignalValueError, + type Guard, InvalidCallbackError, + InvalidSignalValueError, InvalidStoreMutationError, + NullishSignalValueError, + PromiseValueError, ReadonlySignalError, RequiredOwnerError, - DuplicateKeyError, - PromiseValueError, - validateSignalValue, - validateReadValue, + UnsetSignalValueError, validateCallback, + validateReadValue, + validateSignalValue, } diff --git a/src/graph.ts b/src/graph.ts index 12f2b25..e2a449e 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -793,53 +793,53 @@ function makeSubscribe(node: SourceNode, onWatch?: () => Cleanup): () => void { } export { - type Cleanup, - type ComputedOptions, - type EffectCallback, - type EffectNode, - type MaybeCleanup, - type MemoCallback, - type MemoNode, - type Scope, - type ScopeOptions, - type Signal, - type SignalOptions, - type SinkNode, - type StateNode, - type TaskCallback, - type TaskNode, activeOwner, activeSink, batch, batchDepth, + type Cleanup, + type ComputedOptions, createScope, - DEFAULT_EQUALITY, DEEP_EQUALITY, - isEqual, - SKIP_EQUALITY, + DEFAULT_EQUALITY, + type EffectCallback, + type EffectNode, FLAG_CHECK, FLAG_CLEAN, FLAG_DIRTY, FLAG_RELINK, flush, + isEqual, link, + type MaybeCleanup, + type MemoCallback, + type MemoNode, makeSubscribe, propagate, refresh, registerCleanup, runCleanup, runEffect, + type Scope, + type ScopeOptions, + type Signal, + type SignalOptions, + type SinkNode, + SKIP_EQUALITY, + type StateNode, scheduleEffect, setState, - trimSources, + type TaskCallback, + type TaskNode, TYPE_COLLECTION, TYPE_LIST, TYPE_MEMO, TYPE_SENSOR, - TYPE_STATE, TYPE_SLOT, + TYPE_STATE, TYPE_STORE, TYPE_TASK, + trimSources, unlink, unown, untrack, diff --git a/src/nodes/collection.ts b/src/nodes/collection.ts index 00cc4f3..5610c1a 100644 --- a/src/nodes/collection.ts +++ b/src/nodes/collection.ts @@ -573,13 +573,13 @@ function isCollection = Signal>( /* === Exports === */ export { - createCollection, - deriveCollection, - isCollection, type Collection, type CollectionCallback, type CollectionChanges, type CollectionOptions, type CollectionSource, + createCollection, type DeriveCollectionCallback, + deriveCollection, + isCollection, } diff --git a/src/nodes/effect.ts b/src/nodes/effect.ts index 028179a..01e9a7a 100644 --- a/src/nodes/effect.ts +++ b/src/nodes/effect.ts @@ -235,9 +235,9 @@ function match( } export { - type MaybePromise, - type MatchHandlers, - type SingleMatchHandlers, createEffect, + type MatchHandlers, + type MaybePromise, match, + type SingleMatchHandlers, } diff --git a/src/nodes/list.ts b/src/nodes/list.ts index 68bc72b..29618bf 100644 --- a/src/nodes/list.ts +++ b/src/nodes/list.ts @@ -643,14 +643,14 @@ function isList = MutableSignal>( /* === Exports === */ export { + createList, type DiffResult, + getKeyGenerator, + isList, type KeyConfig, + keysEqual, type List, type ListOptions, - type UnknownRecord, - createList, - isList, - getKeyGenerator, - keysEqual, TYPE_LIST, + type UnknownRecord, } diff --git a/src/signal.ts b/src/signal.ts index 21607e0..485d44e 100644 --- a/src/signal.ts +++ b/src/signal.ts @@ -158,11 +158,11 @@ function isMutableSignal(value: unknown): value is MutableSignal { } export { - type MutableSignal, createComputed, - createSignal, createMutableSignal, + createSignal, isComputed, - isSignal, isMutableSignal, + isSignal, + type MutableSignal, } diff --git a/src/util.ts b/src/util.ts index ac7df23..af0d146 100644 --- a/src/util.ts +++ b/src/util.ts @@ -66,11 +66,11 @@ function valueString(value: unknown): string { /* === Exports === */ export { - isFunction, isAsyncFunction, - isSyncFunction, + isFunction, isObjectOfType, - isSignalOfType, isRecord, + isSignalOfType, + isSyncFunction, valueString, } From b65bf51ff72537d068bfa56a601037fd57a3ca7d Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 18:50:35 +0200 Subject: [PATCH 08/11] Delete PLAN-ci-workflow.md --- PLAN-ci-workflow.md | 124 -------------------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 PLAN-ci-workflow.md diff --git a/PLAN-ci-workflow.md b/PLAN-ci-workflow.md deleted file mode 100644 index 1086cf3..0000000 --- a/PLAN-ci-workflow.md +++ /dev/null @@ -1,124 +0,0 @@ -# PLAN: CI Workflow + Test Gate on Publish - -## Goal - -The repository has **no CI**. The only workflow (`.github/workflows/npm-publish.yml`) publishes to npm on GitHub release **without running a single test** — a release cut from a broken branch ships broken code with provenance attached. Add a CI workflow that runs typecheck, lint, tests, bundle-size regression, and build on every push/PR, and make the publish workflow run the same gate before publishing. - -All commands below were verified to pass locally on `next` (2026-07-10): - -- `bunx tsc --noEmit` → exit 0 -- `bunx biome lint .` → "Checked 49 files… No fixes applied." (exit 0) -- `bun test --path-ignore-patterns='**/regression*'` (what `bun run test` does) → 561 pass -- `bun test test/regression-bundle.test.ts` → 3 pass - -**Important pitfalls already checked:** `bun run lint` is `biome lint --write` (mutating — never use in CI), and `bunx biome ci .` FAILS on this repo (46 formatting errors — the repo is lint-clean but not biome-*format*-clean). CI must use `bunx biome lint .`, not `biome ci` and not `biome check`. - -## Files to touch - -- `.github/workflows/ci.yml` — new file -- `.github/workflows/npm-publish.yml` — add test gate before publish -- `package.json` — add a `check` convenience script (optional, step 3) - -## Implementation steps - -1. **Create `.github/workflows/ci.yml`:** - - ```yaml - name: CI - - on: - push: - branches: [main, next] - pull_request: - branches: [main, next] - - 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 - run: bunx biome lint . - - - 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: - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Performance regression (informational — shared runners are noisy) - run: bun test test/regression-performance.test.ts - ``` - - Match the existing workflow's indentation style (4 spaces, as in `npm-publish.yml`). - -2. **Gate the publish workflow.** In `.github/workflows/npm-publish.yml`, insert after the "Install all dependencies" step and **before** the "Build package" step: - - ```yaml - - name: Typecheck - run: bunx tsc --noEmit - - - name: Lint - run: bunx biome lint . - - - name: Run tests - run: bun run test - - - name: Bundle size regression - run: bun test test/regression-bundle.test.ts - ``` - - Keep everything else in that workflow unchanged (the provenance permissions block, version/tag detection, Node setup, `npm publish` step). - -3. **(Optional but recommended)** Add to `package.json` scripts so the gate is one command locally and in CI: - - ```json - "check": "bunx tsc --noEmit && bunx biome lint . && bun run test && bun test test/regression-bundle.test.ts" - ``` - - If added, CI steps 3–6 in `ci.yml` and the publish gate can each be collapsed to `bun run check` — but keep separate named steps in `ci.yml` for readable failure annotations; use the script only in `npm-publish.yml`. - -4. **Verify** by pushing to a branch and opening a draft PR against `next`; confirm both jobs run and the `test` job is green. Confirm the `performance` job is marked neutral/failed without failing the run (because of `continue-on-error`). - -## Edge cases a weaker model would likely miss - -- **`bun run test` already excludes regression tests** (`--path-ignore-patterns=**/regression*` in package.json) — do not run plain `bun test`, which would include the performance regression suite and make CI flaky. -- **Performance regression tests compare against the published stable release** (`@zeix/cause-effect-stable` npm alias) with a 20% margin and 1 ms floor (`test/regression-performance.test.ts`). On shared GitHub runners these timings are noisy — that's why the `performance` job is separate with `continue-on-error: true`. Do NOT put it in the required job, and do NOT delete it either; it's useful signal on large regressions. -- **Bundle regression is deterministic** (builds with `Bun.build` and measures bytes) — it belongs in the required job. -- **`biome ci` / `biome check` include formatting and fail on this repo** (46 format diffs, verified). Only `biome lint` is clean. If someone later formats the repo, CI can be upgraded to `biome ci .` — leave a one-line comment in the workflow noting this. -- **`bun install --frozen-lockfile`** — the publish workflow currently uses plain `bun install`; keep publish as-is (it must tolerate lockfile drift at release time is arguable, but out of scope), use frozen in CI so PRs can't silently change dependencies. -- **`typescript` is a devDependency and a peerDependency** — `bun install` installs it; `bunx tsc` resolves the local 6.x, not a global one. No extra setup needed. -- **The `types/` directory is gitignored?** Check: it is committed (exists in repo). `bunx tsc --noEmit` uses `tsconfig.json`, which *excludes* `types/` and `index.js` — no conflict with stale build artifacts. -- **Branch protection is not configurable from the repo** — after merging, the user must mark the `test` job as a required status check in GitHub settings for `main` and `next`. Note this in the PR description. - -## Acceptance criteria - -1. `ci.yml` exists; on a test PR, the `test` job passes all six steps and the `performance` job runs without being able to fail the overall check. -2. `npm-publish.yml` contains typecheck/lint/test/bundle-regression steps that execute before "Build package". -3. `act`-free validation: `bunx tsc --noEmit && bunx biome lint . && bun run test && bun test test/regression-bundle.test.ts && bun run build` exits 0 locally (this is exactly what the required job runs). -4. YAML is valid: `bunx yaml-lint .github/workflows/ci.yml` or simply GitHub's workflow parser accepting the file on push (no "workflow file issue" annotation). From 820c0ff34833677a68181f0f5e6014642c23cb5c Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 19:32:46 +0200 Subject: [PATCH 09/11] fix(package): correct npm packaging for exports, tree-shaking, and peer-dep metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite distribution metadata so the package resolves correctly and tree-shakes in all supported toolchains: - Add explicit `exports` map (`.`: types -> bun -> default, plus `./package.json`); the `bun` condition lets Bun consumers resolve TS source directly, `default` serves the bundled index.js - Remove `"module": "index.ts"` — it pointed at raw TypeScript, breaking webpack and older Rollup configs - Publish the unminified ESM bundle as index.js (~50 KB readable) instead of the minified artifact; consumers' bundlers minify anyway - Make TypeScript an optional peer dependency (`peerDependenciesMeta`) so JS-only consumers don't get an unresolvable-peer warning - Replace fragile `.npmignore` negation patterns with a `files` allowlist - Remove unused index.dev.js artifact Note: `sideEffects: false` from the original plan was dropped because it breaks `bun build` — Bun tree-shakes the barrel re-exports away during the package's own build, producing a broken 827-byte output. Tree-shaking for consumers is preserved because the published index.js is a single inlined ESM file (verified: bundleCoreGzipped stays at 2476B). This is a minor (not patch) version bump: the exports map intentionally blocks deep imports into package internals. --- .npmignore | 52 -- CHANGELOG.md | 4 + PLAN-package-exports.md | 101 --- biome.json | 2 +- eslint.config.js | 2 +- index.dev.js | 1872 -------------------------------------- index.js | 1873 ++++++++++++++++++++++++++++++++++++++- package.json | 23 +- 8 files changed, 1899 insertions(+), 2030 deletions(-) delete mode 100644 .npmignore delete mode 100644 PLAN-package-exports.md delete mode 100644 index.dev.js diff --git a/.npmignore b/.npmignore deleted file mode 100644 index fbd7333..0000000 --- a/.npmignore +++ /dev/null @@ -1,52 +0,0 @@ -# Dependencies -node_modules/ -bun.lock - -# Build artifacts -*.tsbuildinfo - -# Build tools configuration -tsconfig.json -tsconfig.build.json -biome.json -eslint.config.js -context7.json -.prettierrc -.editorconfig - -# Tests, benchmarks, and internal documentation -test/ -bench/ -docs/ -adr/ -GUIDE.md -REQUIREMENTS.md -RECIPES.md -REACT_INTEGRATION.md -CHANGELOG.md - -# IDE and editor files -.agents/ -.claude/ -.cursorrules -.DS_Store -.vibe/ -.zed/ - -# GitHub -.github/ -.gitignore - -# OS and temporary files -*.log -*.tmp - -# Keep these essential files for published package -!index.js -!index.ts -!index.dev.js -!src/ -!types/ -!package.json -!README.md -!LICENSE diff --git a/CHANGELOG.md b/CHANGELOG.md index 614ada7..7132557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- **Package distribution metadata overhauled** (`package.json`, `.npmignore`): The published entry point is now the *unminified* ESM bundle (`index.js`, ~50 KB readable) instead of the previous minified artifact; consumers' bundlers minify anyway, and unminified source improves debugging and bug reports. An explicit `exports` map (`.`: `types` → `bun` → `default`, plus `./package.json`) replaces the bare `main`/`module` pair: the `"bun"` condition lets Bun consumers resolve TypeScript source directly, while `"default"` serves the bundled `index.js` to all other toolchains. The `"module": "index.ts"` field is removed — it pointed at raw TypeScript, which broke webpack and older Rollup configs that transcribe `node_modules`. TypeScript is now an *optional* peer dependency (`peerDependenciesMeta`), so JS-only consumers no longer get an unresolvable-peer warning on install. A `files` allowlist replaces the fragile `.npmignore` negation patterns. The unused `index.dev.js` artifact is removed. **Migration:** this is a **minor** (not patch) version bump. The `exports` map intentionally blocks deep imports into package internals (e.g. `@zeix/cause-effect/src/nodes/list.ts`) — nothing in the repo or docs advertised these, and the barrel re-exports the full public API, but code relying on deep imports must switch to named imports from the package root. If your toolchain read the `module` field to consume TypeScript source, switch to the `"bun"` exports condition or import the bundled `index.js`. + ### Added - **`EffectConvergenceError`**: New error class (exported from the package root), thrown when queued effects keep re-triggering each other without settling within 1000 flush passes. Typical triggers: an effect that unconditionally writes a signal it reads (`createEffect(() => count.set(count.get() + 1))`), or two effects that write each other's dependencies. The error surfaces synchronously from the `set()`/`update()`/`batch()`/`createEffect()` call that triggered the runaway; other queued effects still run before it is thrown. diff --git a/PLAN-package-exports.md b/PLAN-package-exports.md deleted file mode 100644 index c9d8337..0000000 --- a/PLAN-package-exports.md +++ /dev/null @@ -1,101 +0,0 @@ -# PLAN: Correct npm Packaging (exports map, tree-shaking, peer-dep metadata) - -## Goal - -Fix distribution metadata so the package resolves correctly and tree-shakes in all supported toolchains. Current problems in `package.json`: - -1. **No `exports` map** — no explicit entry conditions, no `types` condition, and deep imports into package internals are uncontrolled. -2. **`"module": "index.ts"` points at TypeScript source.** Bundlers that honor the `module` field (webpack, older Rollup configs) will try to parse raw TS from `node_modules`, which most consumer configs exclude from transpilation → build errors. `module` must point to JS. -3. **No `"sideEffects": false`** — REQUIREMENTS.md explicitly demands "the library must remain tree-shakable", but without this flag webpack retains the whole bundle. (Verified: the codebase has no module-level side effects — only const declarations, class definitions, and pure helpers; `Object.getPrototypeOf(async () => {})` in `src/util.ts` and the module-scope `WeakSet`/arrays in `graph.ts`/`slot.ts` are allocation-only, side-effect-free.) -4. **`main` is a *minified* bundle** (`index.js`, built with `bun build --minify`). Publishing minified code hurts consumer debugging and bug reports; consumers' bundlers minify anyway. The unminified `index.dev.js` exists but nothing points to it. -5. **`typescript` is a hard `peerDependency`** — JS-only consumers get an unresolvable-peer warning on every install. It should be optional. -6. **Packaging is controlled by a fragile `.npmignore`** with negation patterns; an explicit `files` allowlist is safer. - -## Files to touch - -- `package.json` -- `.npmignore` — delete (replaced by `files`) -- `test/regression-bundle.test.ts` — no change needed (it builds from `index.ts` on the fly, not from the shipped `index.js`); verify only. -- `REQUIREMENTS.md` — no change needed unless acceptance step 5 finds issues. - -## Implementation steps - -1. **Swap the roles of the two build outputs** so the published entry is the *unminified* ESM bundle. In `package.json` `scripts.build`, change: - - ``` - bunx tsc --project tsconfig.build.json && bun build index.ts --outdir ./ --minify && bun build index.ts --outfile index.dev.js - ``` - - to: - - ``` - bunx tsc --project tsconfig.build.json && bun build index.ts --outfile index.js - ``` - - and delete `index.dev.js` from the repo (`git rm index.dev.js`). The minified artifact serves no consumer (bundlers minify; the size budget is enforced by `test/regression-bundle.test.ts`, which builds its own minified bundle from source). If the user prefers to keep a minified file for CDN-style usage, instead keep both files and point a `"production"` exports condition at the minified one — but the default/simple path is one unminified `index.js`. - -2. **Rewrite the packaging fields in `package.json`:** - - ```json - "main": "index.js", - "types": "types/index.d.ts", - "exports": { - ".": { - "types": "./types/index.d.ts", - "bun": "./index.ts", - "default": "./index.js" - }, - "./package.json": "./package.json" - }, - "sideEffects": false, - "files": [ - "index.js", - "index.ts", - "src", - "types", - "SECURITY.md" - ], - ``` - - - **Delete the `"module": "index.ts"` field entirely.** With an `exports` map, `module` is ignored by modern resolvers; leaving it pointing at TS keeps the webpack hazard alive for tools that still read it. Do not repoint it at `index.js` — just remove it. - - The `"bun"` condition lets Bun consumers (Le Truc, per REQUIREMENTS.md) resolve the TS source directly — this is why `index.ts` and `src/` stay in `files`. - - `README.md` and `LICENSE` are always included by npm automatically; they do not need to be in `files`. - - Key order inside the `"."` conditions matters: `"types"` must come first, `"default"` last. - -3. **Make the TypeScript peer dependency optional:** - - ```json - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - ``` - -4. **Delete `.npmignore`.** With a `files` allowlist present, npm ignores `.npmignore` for inclusion logic anyway; keeping both invites drift. - -5. **Validate the package shape:** - - `bun run build` (regenerates `types/` and `index.js`). - - `npm pack --dry-run` — inspect the file list: exactly `index.js`, `index.ts`, `src/**`, `types/**`, `package.json`, `README.md`, `LICENSE`, `SECURITY.md`. No `test/`, no `adr/`, no `.agents/`, no `bench/`. - - `bunx publint` — must report no errors (warnings about `bun` condition ordering are acceptable if any). - - `bunx @arethetypeswrong/cli --pack .` — must show ESM resolution finding `types/index.d.ts` for the `import` condition; "no types for require" is expected and fine (the library is ESM-only by design; see the comment in `src/graph.ts:187–191` about live bindings — do NOT add a CJS build to satisfy the tool). - - Smoke test in a scratch dir: `npm pack`, then in `/tmp/pkgtest` create `package.json` (`"type": "module"`), `npm i `, and run `node -e "import('@zeix/cause-effect').then(m => console.log(typeof m.createState))"` → prints `function`. - -## Edge cases a weaker model would likely miss - -- **Do NOT add a CommonJS build.** `src/graph.ts` documents that `activeSink`/`activeOwner`/`batchDepth` are exported mutable `let` bindings relying on ESM live-binding semantics; a CJS transform snapshots them and silently breaks dependency tracking. ESM-only is a load-bearing design decision (REQUIREMENTS.md), not an oversight. -- **The `exports` map breaks deep imports** like `@zeix/cause-effect/src/nodes/list.ts`. Nothing in the repo or docs advertises deep imports, and the barrel re-exports everything, so this is acceptable — but mention it in the changelog entry (via the changelog-keeper skill) as a potentially breaking packaging change; consider a minor (not patch) version bump. -- **The `"bun"` condition must appear before `"default"`** or Bun will never reach it. Conditions are matched in object order. -- **`sideEffects: false` is a promise, not a flag** — if anyone later adds a module-level side effect (e.g., a global registration, a polyfill import), tree-shaking will silently drop it in consumer builds. Add a short comment in ARCHITECTURE.md or a note in the cause-effect-dev skill reference if the team wants a guardrail; at minimum, the core-tree-shaking regression test (`bundleCoreGzipped`) partially covers this. -- **`types/index.d.ts` is generated by `tsc --project tsconfig.build.json`** and the publish workflow runs `bun run build` before `npm publish` — so `files: ["types"]` is safe in CI. But `npm pack` from a fresh clone without building would produce a tarball missing fresh types; add `"prepack": "bun run build"`? **No** — npm runs `prepack` with npm's own node, and the build needs Bun. The publish workflow already builds; leave it, but note in the plan-executor's PR description that `npm pack` requires a prior `bun run build`. -- **`index.dev.js` removal**: grep the repo for references first (`grep -rn "index.dev" --exclude-dir=node_modules .`) — as of today it appears only in `package.json`'s build script and `.npmignore`; both are being edited by this plan. If it also appears in docs, update them. -- **The regression bundle test is independent of these changes** (it builds from `index.ts` with `Bun.build`), so size limits cannot regress from this plan — but run `bun run regression` anyway as a guard. - -## Acceptance criteria - -1. `npm pack --dry-run` lists exactly the intended files (see step 5) — no test/dev/tooling files. -2. `bunx publint` passes with no errors. -3. `bunx @arethetypeswrong/cli --pack .` shows correct ESM + types resolution (no ❌ for the `import` entrypoint; `require` failures acceptable/ESM-only). -4. Node smoke test (step 5, last bullet) prints `function`. -5. `package.json` contains `exports` (with `types` first), `sideEffects: false`, `files`, `peerDependenciesMeta.typescript.optional: true`, and no `module` field; `.npmignore` is deleted. -6. `bun test` and `bun run regression` still pass; `bun run build` produces `index.js` (unminified) and `types/`. diff --git a/biome.json b/biome.json index 4664dff..912700e 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!index.js", "!index.dev.js", "!**/*.d.ts"] + "includes": ["**", "!index.js", "!**/*.d.ts"] }, "formatter": { "enabled": true, diff --git a/eslint.config.js b/eslint.config.js index 0d0e833..7904091 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' export default [ // Global ignores to prevent warnings about these files { - ignores: ['index.js', 'index.dev.js', 'types/**/*.d.ts', '**/*.min.js'], + ignores: ['index.js', 'types/**/*.d.ts', '**/*.min.js'], }, { files: ['**/*.{js,mjs,cjs,ts}'], diff --git a/index.dev.js b/index.dev.js deleted file mode 100644 index eee51ae..0000000 --- a/index.dev.js +++ /dev/null @@ -1,1872 +0,0 @@ -// src/util.ts -var ASYNC_FUNCTION_PROTO = Object.getPrototypeOf(async () => {}); -function isFunction(fn) { - return typeof fn === "function"; -} -function isAsyncFunction(fn) { - return isFunction(fn) && Object.getPrototypeOf(fn) === ASYNC_FUNCTION_PROTO; -} -function isSyncFunction(fn) { - return isFunction(fn) && Object.getPrototypeOf(fn) !== ASYNC_FUNCTION_PROTO; -} -function isObjectOfType(value, type) { - return Object.prototype.toString.call(value) === `[object ${type}]`; -} -function isSignalOfType(value, type) { - return value != null && value[Symbol.toStringTag] === type; -} -function isRecord(value) { - return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype; -} -function valueString(value) { - if (typeof value === "string") - return `"${value}"`; - if (value != null && typeof value === "object") { - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - return String(value); -} - -// src/errors.ts -class CircularDependencyError extends Error { - constructor(where) { - super(`[${where}] Circular dependency detected`); - this.name = "CircularDependencyError"; - } -} - -class EffectConvergenceError extends Error { - constructor(passes) { - super(`[Effect] Effects did not settle after ${passes} flush passes — check for effects that write to signals they depend on`); - this.name = "EffectConvergenceError"; - } -} - -class NullishSignalValueError extends TypeError { - constructor(where) { - super(`[${where}] Signal value cannot be null or undefined`); - this.name = "NullishSignalValueError"; - } -} - -class UnsetSignalValueError extends Error { - constructor(where) { - super(`[${where}] Signal value is unset`); - this.name = "UnsetSignalValueError"; - } -} - -class InvalidSignalValueError extends TypeError { - constructor(where, value) { - super(`[${where}] Signal value ${valueString(value)} is invalid`); - this.name = "InvalidSignalValueError"; - } -} - -class InvalidCallbackError extends TypeError { - constructor(where, value) { - super(`[${where}] Callback ${valueString(value)} is invalid`); - this.name = "InvalidCallbackError"; - } -} - -class ReadonlySignalError extends Error { - constructor(where) { - super(`[${where}] Signal is read-only`); - this.name = "ReadonlySignalError"; - } -} - -class RequiredOwnerError extends Error { - constructor(where) { - super(`[${where}] Active owner is required`); - this.name = "RequiredOwnerError"; - } -} - -class PromiseValueError extends TypeError { - constructor(where) { - super(`[${where}] Callback returned a Promise — use an async callback to create a Task instead`); - this.name = "PromiseValueError"; - } -} - -class DuplicateKeyError extends Error { - constructor(where, key, value) { - super(`[${where}] Could not add key "${key}"${value != null ? ` with value ${JSON.stringify(value)}` : ""} because it already exists`); - this.name = "DuplicateKeyError"; - } -} - -class InvalidStoreMutationError extends TypeError { - constructor(prop, action) { - const guidance = action === "delete" ? `use store.remove(${JSON.stringify(prop)})` : `use store.${prop}.set(value), store.set(next), or store.add(key, value)`; - super(`[Store] Cannot ${action} property "${prop}" directly — ${guidance}`); - this.name = "InvalidStoreMutationError"; - } -} -function validateSignalValue(where, value, guard) { - if (value == null) - throw new NullishSignalValueError(where); - if (guard && !guard(value)) - throw new InvalidSignalValueError(where, value); -} -function validateReadValue(where, value) { - if (value == null) - throw new UnsetSignalValueError(where); -} -function validateCallback(where, value, guard = isFunction) { - if (!guard(value)) - throw new InvalidCallbackError(where, value); -} -// src/graph.ts -var TYPE_STATE = "State"; -var TYPE_MEMO = "Memo"; -var TYPE_TASK = "Task"; -var TYPE_SENSOR = "Sensor"; -var TYPE_LIST = "List"; -var TYPE_COLLECTION = "Collection"; -var TYPE_STORE = "Store"; -var TYPE_SLOT = "Slot"; -var FLAG_CLEAN = 0; -var FLAG_CHECK = 1 << 0; -var FLAG_DIRTY = 1 << 1; -var FLAG_RUNNING = 1 << 2; -var FLAG_RELINK = 1 << 3; -var activeSink = null; -var activeOwner = null; -var queuedEffects = []; -var batchDepth = 0; -var flushing = false; -var DEFAULT_EQUALITY = (a, b) => a === b; -var SKIP_EQUALITY = (_a, _b) => false; -var deepEqual = (a, b) => deepEqualInner(a, b, new WeakSet); -var deepEqualInner = (a, b, seen) => { - if (Object.is(a, b)) - return true; - if (typeof a !== typeof b) - return false; - if (a == null || typeof a !== "object" || b == null || typeof b !== "object") - return false; - if (seen.has(a)) - return true; - seen.add(a); - try { - const aIsArray = Array.isArray(a); - if (aIsArray !== Array.isArray(b)) - return false; - if (aIsArray) { - const aa = a; - const ba = b; - if (aa.length !== ba.length) - return false; - for (let i = 0;i < aa.length; i++) - if (!deepEqualInner(aa[i], ba[i], seen)) - return false; - return true; - } - if (a instanceof Date && b instanceof Date) - return a.getTime() === b.getTime(); - if (a instanceof RegExp && b instanceof RegExp) - return a.source === b.source && a.flags === b.flags; - if (isRecord(a) && isRecord(b)) { - const aKeys = Object.keys(a); - if (aKeys.length !== Object.keys(b).length) - return false; - for (const key of aKeys) { - if (!(key in b)) - return false; - if (!deepEqualInner(a[key], b[key], seen)) - return false; - } - return true; - } - return false; - } finally { - seen.delete(a); - } -}; -var DEEP_EQUALITY = (a, b) => deepEqual(a, b); -var isEqual = DEEP_EQUALITY; -function isValidEdge(checkEdge, node) { - const sourcesTail = node.sourcesTail; - if (sourcesTail) { - let edge = node.sources; - while (edge) { - if (edge === checkEdge) - return true; - if (edge === sourcesTail) - break; - edge = edge.nextSource; - } - } - return false; -} -function link(source, sink) { - const prevSource = sink.sourcesTail; - if (prevSource?.source === source) - return; - let nextSource = null; - const isRecomputing = sink.flags & FLAG_RUNNING; - if (isRecomputing) { - nextSource = prevSource ? prevSource.nextSource : sink.sources; - if (nextSource?.source === source) { - sink.sourcesTail = nextSource; - return; - } - } - const prevSink = source.sinksTail; - if (prevSink?.sink === sink && (!isRecomputing || isValidEdge(prevSink, sink))) - return; - const newEdge = { source, sink, nextSource, prevSink, nextSink: null }; - sink.sourcesTail = source.sinksTail = newEdge; - if (prevSource) - prevSource.nextSource = newEdge; - else - sink.sources = newEdge; - if (prevSink) - prevSink.nextSink = newEdge; - else - source.sinks = newEdge; -} -function unlink(edge) { - const { source, nextSource, nextSink, prevSink } = edge; - if (nextSink) - nextSink.prevSink = prevSink; - else - source.sinksTail = prevSink; - if (prevSink) - prevSink.nextSink = nextSink; - else - source.sinks = nextSink; - if (!source.sinks) { - if (source.stop) { - source.stop(); - source.stop = undefined; - } - if ("sources" in source && source.sources) { - const sinkNode = source; - sinkNode.sourcesTail = null; - trimSources(sinkNode); - sinkNode.flags |= FLAG_DIRTY; - } - } - return nextSource; -} -function trimSources(node) { - const tail = node.sourcesTail; - let source = tail ? tail.nextSource : node.sources; - while (source) - source = unlink(source); - if (tail) - tail.nextSource = null; - else - node.sources = null; -} -function propagate(node, newFlag = FLAG_DIRTY) { - const flags = node.flags; - if ("sinks" in node) { - if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) - return; - node.flags = flags | newFlag; - if ("controller" in node && node.controller) { - node.controller.abort(); - node.controller = undefined; - } - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink, FLAG_CHECK); - } else { - if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) - return; - const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK); - node.flags = flags & FLAG_RUNNING | newFlag; - if (!wasQueued) - queuedEffects.push(node); - } -} -function setState(node, next) { - if (node.equals(node.value, next)) - return; - node.value = next; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); -} -function registerCleanup(owner, fn) { - if (!owner.cleanup) - owner.cleanup = fn; - else if (Array.isArray(owner.cleanup)) - owner.cleanup.push(fn); - else - owner.cleanup = [owner.cleanup, fn]; -} -function runCleanup(owner) { - if (!owner.cleanup) - return; - if (Array.isArray(owner.cleanup)) - for (let i = 0;i < owner.cleanup.length; i++) - owner.cleanup[i](); - else - owner.cleanup(); - owner.cleanup = null; -} -function recomputeMemo(node) { - const prevWatcher = activeSink; - activeSink = node; - node.sourcesTail = null; - node.flags = FLAG_RUNNING; - let changed = false; - try { - const next = node.fn(node.value); - if (next instanceof Promise) - throw new PromiseValueError(TYPE_MEMO); - if (node.error || !node.equals(next, node.value)) { - node.value = next; - node.error = undefined; - changed = true; - } - } catch (err) { - changed = true; - node.error = err instanceof Error ? err : new Error(String(err)); - } finally { - activeSink = prevWatcher; - trimSources(node); - } - if (changed) { - for (let e = node.sinks;e; e = e.nextSink) - if (e.sink.flags & FLAG_CHECK) - e.sink.flags |= FLAG_DIRTY; - } - node.flags = FLAG_CLEAN; -} -function recomputeTask(node) { - node.controller?.abort(); - const controller = new AbortController; - node.controller = controller; - node.error = undefined; - const prevWatcher = activeSink; - activeSink = node; - node.sourcesTail = null; - node.flags = FLAG_RUNNING; - let promise; - try { - promise = node.fn(node.value, controller.signal); - } catch (err) { - node.controller = undefined; - node.error = err instanceof Error ? err : new Error(String(err)); - node.flags = FLAG_CLEAN; - setState(node.pendingNode, false); - return; - } finally { - activeSink = prevWatcher; - trimSources(node); - } - setState(node.pendingNode, true); - promise.then((next) => { - if (controller.signal.aborted) - return; - node.controller = undefined; - batch(() => { - if (node.error || !node.equals(next, node.value)) { - node.value = next; - node.error = undefined; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - } - setState(node.pendingNode, false); - }); - }, (err) => { - if (controller.signal.aborted) - return; - node.controller = undefined; - const error = err instanceof Error ? err : new Error(String(err)); - batch(() => { - if (!node.error || error.name !== node.error.name || error.message !== node.error.message) { - node.error = error; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - } - setState(node.pendingNode, false); - }); - }); - node.flags = FLAG_CLEAN; -} -function runEffect(node) { - runCleanup(node); - const prevContext = activeSink; - const prevOwner = activeOwner; - activeSink = activeOwner = node; - node.sourcesTail = null; - node.flags = FLAG_RUNNING; - try { - const out = node.fn(); - if (typeof out === "function") - registerCleanup(node, out); - } finally { - activeSink = prevContext; - activeOwner = prevOwner; - trimSources(node); - node.flags &= FLAG_DIRTY | FLAG_CHECK; - } -} -function refresh(node) { - if (node.flags & FLAG_CHECK) { - for (let e = node.sources;e; e = e.nextSource) { - if ("fn" in e.source) - refresh(e.source); - if (node.flags & FLAG_DIRTY) - break; - } - } - if (node.flags & FLAG_RUNNING) { - throw new CircularDependencyError("controller" in node ? TYPE_TASK : ("value" in node) ? TYPE_MEMO : "Effect"); - } - if (node.flags & FLAG_DIRTY) { - if ("controller" in node) - recomputeTask(node); - else if ("value" in node) - recomputeMemo(node); - else - runEffect(node); - } else { - node.flags = FLAG_CLEAN; - } -} -var MAX_FLUSH_PASSES = 1000; -function flush() { - if (flushing) - return; - flushing = true; - let errors; - let passes = 0; - try { - while (queuedEffects.length > 0) { - if (++passes > MAX_FLUSH_PASSES) { - queuedEffects.length = 0; - if (!errors) - errors = []; - errors.push(new EffectConvergenceError(MAX_FLUSH_PASSES)); - break; - } - const batch = queuedEffects.slice(); - queuedEffects.length = 0; - for (let i = 0;i < batch.length; i++) { - const effect = batch[i]; - if (effect.flags & FLAG_RUNNING) - continue; - if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) { - try { - refresh(effect); - } catch (err) { - if (!errors) - errors = []; - errors.push(err); - } - } - } - } - } finally { - flushing = false; - } - if (errors) { - if (errors.length === 1) - throw errors[0]; - throw new AggregateError(errors, "Multiple effects threw during flush"); - } -} -function scheduleEffect(node) { - if (node.flags & (FLAG_DIRTY | FLAG_CHECK)) { - queuedEffects.push(node); - if (batchDepth === 0) - flush(); - } -} -function batch(fn) { - batchDepth++; - try { - fn(); - } finally { - batchDepth--; - if (batchDepth === 0) - flush(); - } -} -function untrack(fn) { - const prev = activeSink; - activeSink = null; - try { - return fn(); - } finally { - activeSink = prev; - } -} -function createScope(fn, options) { - const prevOwner = activeOwner; - const scope = { cleanup: null }; - activeOwner = scope; - const dispose = () => runCleanup(scope); - try { - const out = fn(); - if (typeof out === "function") - registerCleanup(scope, out); - return dispose; - } finally { - activeOwner = prevOwner; - if (!options?.root && prevOwner) - registerCleanup(prevOwner, dispose); - } -} -function unown(fn) { - const prev = activeOwner; - activeOwner = null; - try { - return fn(); - } finally { - activeOwner = prev; - } -} -function makeSubscribe(node, onWatch) { - return onWatch ? () => { - if (activeSink) { - if (!node.sinks) - node.stop = onWatch(); - link(node, activeSink); - } - } : () => { - if (activeSink) - link(node, activeSink); - }; -} -// src/nodes/state.ts -function createState(value, options) { - validateSignalValue(TYPE_STATE, value, options?.guard); - const node = { - value, - sinks: null, - sinksTail: null, - equals: options?.equals ?? DEFAULT_EQUALITY, - guard: options?.guard - }; - return { - [Symbol.toStringTag]: TYPE_STATE, - get() { - if (activeSink) - link(node, activeSink); - return node.value; - }, - set(next) { - validateSignalValue(TYPE_STATE, next, node.guard); - setState(node, next); - }, - update(fn) { - validateCallback(TYPE_STATE, fn); - const next = fn(node.value); - validateSignalValue(TYPE_STATE, next, node.guard); - setState(node, next); - } - }; -} -function isState(value) { - return isSignalOfType(value, TYPE_STATE); -} - -// src/nodes/list.ts -function keysEqual(a, b) { - if (a.length !== b.length) - return false; - for (let i = 0;i < a.length; i++) - if (a[i] !== b[i]) - return false; - return true; -} -function getKeyGenerator(keyConfig) { - let keyCounter = 0; - const contentBased = typeof keyConfig === "function"; - return [ - typeof keyConfig === "string" ? () => `${keyConfig}${keyCounter++}` : contentBased ? (item) => keyConfig(item) || String(keyCounter++) : () => String(keyCounter++), - contentBased - ]; -} -function diffPositional(prev, next, prevKeys, generateKey, itemEquals) { - const add = {}; - const change = {}; - const remove = {}; - const nextKeys = []; - let changed = false; - const minLen = Math.min(prev.length, next.length); - for (let i = 0;i < minLen; i++) { - const key = prevKeys[i]; - nextKeys.push(key); - if (!itemEquals(prev[i], next[i])) { - change[key] = next[i]; - changed = true; - } - } - for (let i = minLen;i < next.length; i++) { - const val = next[i]; - const key = generateKey(val); - nextKeys.push(key); - add[key] = val; - changed = true; - } - for (let i = minLen;i < prev.length; i++) { - remove[prevKeys[i]] = null; - changed = true; - } - return { add, change, remove, newKeys: nextKeys, changed }; -} -function diffArrays(prev, next, prevKeys, generateKey, contentBased, itemEquals) { - if (!contentBased) - return diffPositional(prev, next, prevKeys, generateKey, itemEquals); - const add = {}; - const change = {}; - const remove = {}; - const nextKeys = []; - let changed = false; - const prevByKey = new Map; - for (let i = 0;i < prev.length; i++) { - const key = prevKeys[i]; - const item = prev[i]; - if (key && item !== undefined) - prevByKey.set(key, item); - } - const seenKeys = new Set; - for (let i = 0;i < next.length; i++) { - const val = next[i]; - validateSignalValue(`${TYPE_LIST} item at index ${i}`, val); - const key = generateKey(val); - if (seenKeys.has(key)) - throw new DuplicateKeyError(TYPE_LIST, key, val); - nextKeys.push(key); - seenKeys.add(key); - if (!prevByKey.has(key)) { - add[key] = val; - changed = true; - } else if (!itemEquals(prevByKey.get(key), val)) { - change[key] = val; - changed = true; - } - } - for (const [key] of prevByKey) { - if (!seenKeys.has(key)) { - remove[key] = null; - changed = true; - } - } - if (!changed && !keysEqual(prevKeys, nextKeys)) - changed = true; - return { add, change, remove, newKeys: nextKeys, changed }; -} -function createList(value, options) { - validateSignalValue(TYPE_LIST, value, Array.isArray); - const signals = new Map; - let keys = []; - const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig); - const itemEquals = options?.itemEquals ?? DEEP_EQUALITY; - const itemFactory = options?.createItem ?? ((item) => createState(item, { equals: itemEquals })); - const buildValue = () => { - const result = []; - for (const key of keys) { - const v = signals.get(key)?.get(); - if (v !== undefined) - result.push(v); - } - return result; - }; - const node = { - fn: buildValue, - value, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: DEEP_EQUALITY, - error: undefined - }; - const applyChanges = (changes) => { - let structural = false; - for (const key in changes.add) { - const val = changes.add[key]; - validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val); - signals.set(key, itemFactory(val)); - structural = true; - } - let hasChange = false; - for (const _key in changes.change) { - hasChange = true; - break; - } - if (hasChange) { - batch(() => { - for (const key in changes.change) { - const val = changes.change[key]; - validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val); - const signal = signals.get(key); - if (signal) - signal.set(val); - } - }); - } - for (const key in changes.remove) { - signals.delete(key); - const index = keys.indexOf(key); - if (index !== -1) - keys.splice(index, 1); - structural = true; - } - if (structural) - node.flags |= FLAG_RELINK; - return changes.changed; - }; - const subscribe = makeSubscribe(node, options?.watched); - for (let i = 0;i < value.length; i++) { - const val = value[i]; - if (val == null) - throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`); - let key = keys[i]; - if (!key) { - key = generateKey(val); - keys[i] = key; - } - signals.set(key, itemFactory(val)); - } - node.value = value; - node.flags = 0; - const list = { - [Symbol.toStringTag]: TYPE_LIST, - [Symbol.isConcatSpreadable]: true, - *[Symbol.iterator]() { - subscribe(); - for (const key of keys) { - const signal = signals.get(key); - if (signal) - yield signal; - } - }, - get length() { - subscribe(); - return keys.length; - }, - get() { - subscribe(); - if (node.sources) { - if (node.flags) { - const relink = node.flags & FLAG_RELINK; - node.value = untrack(buildValue); - if (relink) { - node.flags = FLAG_DIRTY; - refresh(node); - if (node.error) - throw node.error; - } else { - node.flags = FLAG_CLEAN; - } - } - } else { - refresh(node); - if (node.error) - throw node.error; - } - return node.value; - }, - set(next) { - const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value; - const changes = diffArrays(prev, next, keys, generateKey, contentBased, itemEquals); - if (changes.changed) { - keys = changes.newKeys; - applyChanges(changes); - node.flags |= FLAG_DIRTY; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - } - }, - update(fn) { - list.set(fn(untrack(() => list.get()))); - }, - at(index) { - subscribe(); - const key = keys[index]; - return key !== undefined ? signals.get(key) : undefined; - }, - keys() { - subscribe(); - return keys.values(); - }, - byKey(key) { - subscribe(); - return signals.get(key); - }, - keyAt(index) { - subscribe(); - return keys[index]; - }, - indexOfKey(key) { - subscribe(); - return keys.indexOf(key); - }, - add(value2) { - const key = generateKey(value2); - if (signals.has(key)) - throw new DuplicateKeyError(TYPE_LIST, key, value2); - keys.push(key); - validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value2); - signals.set(key, itemFactory(value2)); - node.flags |= FLAG_DIRTY | FLAG_RELINK; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - return key; - }, - remove(keyOrIndex) { - const key = typeof keyOrIndex === "number" ? keys[keyOrIndex] : keyOrIndex; - if (key === undefined) - return; - const ok = signals.delete(key); - if (ok) { - const index = typeof keyOrIndex === "number" ? keyOrIndex : keys.indexOf(key); - if (index >= 0) - keys.splice(index, 1); - node.flags |= FLAG_DIRTY | FLAG_RELINK; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - } - }, - replace(key, value2) { - const signal = signals.get(key); - if (!signal) - return; - validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value2); - if (itemEquals(untrack(() => signal.get()), value2)) - return; - batch(() => { - signal.set(value2); - node.flags |= FLAG_DIRTY; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - }); - if (batchDepth === 0) - flush(); - }, - sort(compareFn) { - const entries = []; - untrack(() => { - for (const key of keys) { - const v = signals.get(key)?.get(); - if (v !== undefined) - entries.push([key, v]); - } - }); - entries.sort(isFunction(compareFn) ? (a, b) => compareFn(a[1], b[1]) : (a, b) => String(a[1]).localeCompare(String(b[1]))); - const newOrder = []; - for (const [key] of entries) - newOrder.push(key); - if (!keysEqual(keys, newOrder)) { - keys = newOrder; - node.flags |= FLAG_DIRTY; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - } - }, - splice(start, deleteCount, ...items) { - const length = keys.length; - const actualStart = start < 0 ? Math.max(0, length + start) : Math.min(start, length); - const actualDeleteCount = Math.max(0, Math.min(deleteCount ?? Math.max(0, length - Math.max(0, actualStart)), length - actualStart)); - const add = {}; - const remove = {}; - let hasRemove = false; - untrack(() => { - for (let i = 0;i < actualDeleteCount; i++) { - const index = actualStart + i; - const key = keys[index]; - if (key) { - const signal = signals.get(key); - if (signal) { - remove[key] = signal.get(); - hasRemove = true; - } - } - } - }); - const newOrder = keys.slice(0, actualStart); - const change = {}; - let hasAdd = false; - let hasChange = false; - for (const item of items) { - const key = generateKey(item); - if (key in remove) { - delete remove[key]; - change[key] = item; - hasChange = true; - } else if (signals.has(key)) { - throw new DuplicateKeyError(TYPE_LIST, key, item); - } else { - add[key] = item; - hasAdd = true; - } - newOrder.push(key); - } - newOrder.push(...keys.slice(actualStart + actualDeleteCount)); - const changed = hasAdd || hasRemove || hasChange; - if (changed) { - applyChanges({ - add, - change, - remove, - changed - }); - keys = newOrder; - node.flags |= FLAG_DIRTY; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - } - return Object.values(remove); - }, - deriveCollection(cb) { - return deriveCollection(list, cb); - } - }; - return list; -} -function isList(value) { - return isSignalOfType(value, TYPE_LIST); -} - -// src/nodes/memo.ts -function createMemo(fn, options) { - validateCallback(TYPE_MEMO, fn, isSyncFunction); - if (options?.value !== undefined) - validateSignalValue(TYPE_MEMO, options.value, options?.guard); - const node = { - fn, - value: options?.value, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: options?.equals ?? DEFAULT_EQUALITY, - error: undefined, - stop: undefined - }; - const watched = options?.watched; - const subscribe = makeSubscribe(node, watched ? () => watched(() => { - propagate(node); - if (batchDepth === 0) - flush(); - }) : undefined); - return { - [Symbol.toStringTag]: TYPE_MEMO, - get() { - subscribe(); - refresh(node); - if (node.error) - throw node.error; - validateReadValue(TYPE_MEMO, node.value); - return node.value; - } - }; -} -function isMemo(value) { - return isSignalOfType(value, TYPE_MEMO); -} - -// src/nodes/task.ts -function createTask(fn, options) { - validateCallback(TYPE_TASK, fn, isAsyncFunction); - if (options?.value !== undefined) - validateSignalValue(TYPE_TASK, options.value, options?.guard); - const pendingNode = { - value: false, - sinks: null, - sinksTail: null, - equals: DEFAULT_EQUALITY - }; - const node = { - fn, - value: options?.value, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - flags: FLAG_DIRTY, - equals: options?.equals ?? DEFAULT_EQUALITY, - controller: undefined, - error: undefined, - stop: undefined, - pendingNode - }; - const watched = options?.watched; - const subscribe = makeSubscribe(node, watched ? () => watched(() => { - propagate(node); - if (batchDepth === 0) - flush(); - }) : undefined); - const pendingSubscribe = makeSubscribe(pendingNode); - return { - [Symbol.toStringTag]: TYPE_TASK, - get() { - subscribe(); - refresh(node); - if (node.error) - throw node.error; - validateReadValue(TYPE_TASK, node.value); - return node.value; - }, - isPending() { - pendingSubscribe(); - return node.pendingNode.value; - }, - abort() { - node.controller?.abort(); - node.controller = undefined; - setState(node.pendingNode, false); - } - }; -} -function isTask(value) { - return isSignalOfType(value, TYPE_TASK); -} - -// src/nodes/collection.ts -function deriveCollection(source, callback) { - validateCallback(TYPE_COLLECTION, callback); - const isAsync = isAsyncFunction(callback); - const signals = new Map; - let keys = []; - const addSignal = (key) => { - const signal = isAsync ? createTask(async (prev, abort) => { - const itemSignal = untrack(() => source.byKey(key)); - if (!itemSignal) - return prev; - const sourceValue = itemSignal.get(); - if (sourceValue == null) - return prev; - return callback(sourceValue, abort); - }) : createMemo(() => { - const itemSignal = untrack(() => source.byKey(key)); - if (!itemSignal) - return; - const sourceValue = itemSignal.get(); - if (sourceValue == null) - return; - return callback(sourceValue); - }); - signals.set(key, signal); - }; - function syncKeys(nextKeys) { - if (!keysEqual(keys, nextKeys)) { - const nextSet = new Set(nextKeys); - for (const key of keys) - if (!nextSet.has(key)) - signals.delete(key); - for (const key of nextKeys) - if (!signals.has(key)) - addSignal(key); - keys = nextKeys; - node.flags |= FLAG_RELINK; - } - } - function buildValue() { - syncKeys(Array.from(source.keys())); - const result = []; - for (const key of keys) { - try { - const v = signals.get(key)?.get(); - if (v != null) - result.push(v); - } catch (e) { - if (!(e instanceof UnsetSignalValueError)) - throw e; - } - } - return result; - } - const valuesEqual = (a, b) => { - if (a.length !== b.length) - return false; - for (let i = 0;i < a.length; i++) - if (a[i] !== b[i]) - return false; - return true; - }; - const node = { - fn: buildValue, - value: [], - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: valuesEqual, - error: undefined - }; - function ensureFresh() { - if (node.sources) { - if (node.flags) { - node.value = untrack(buildValue); - if (node.flags & FLAG_RELINK) { - node.flags = FLAG_DIRTY; - refresh(node); - if (node.error) - throw node.error; - } else { - node.flags = FLAG_CLEAN; - } - } - } else if (node.sinks) { - refresh(node); - if (node.error) - throw node.error; - } else { - node.value = untrack(buildValue); - } - } - const initialKeys = Array.from(untrack(() => source.keys())); - for (const key of initialKeys) - addSignal(key); - keys = initialKeys; - const collection = { - [Symbol.toStringTag]: TYPE_COLLECTION, - [Symbol.isConcatSpreadable]: true, - *[Symbol.iterator]() { - if (activeSink) - link(node, activeSink); - ensureFresh(); - for (const key of keys) { - const signal = signals.get(key); - if (signal) - yield signal; - } - }, - get length() { - if (activeSink) - link(node, activeSink); - ensureFresh(); - return keys.length; - }, - keys() { - if (activeSink) - link(node, activeSink); - ensureFresh(); - return keys.values(); - }, - get() { - if (activeSink) - link(node, activeSink); - ensureFresh(); - return node.value; - }, - at(index) { - if (activeSink) - link(node, activeSink); - ensureFresh(); - const key = keys[index]; - return key !== undefined ? signals.get(key) : undefined; - }, - byKey(key) { - if (activeSink) - link(node, activeSink); - ensureFresh(); - return signals.get(key); - }, - keyAt(index) { - if (activeSink) - link(node, activeSink); - ensureFresh(); - return keys[index]; - }, - indexOfKey(key) { - if (activeSink) - link(node, activeSink); - ensureFresh(); - return keys.indexOf(key); - }, - deriveCollection(cb) { - return deriveCollection(collection, cb); - } - }; - return collection; -} -function createCollection(watched, options) { - const value = options?.value ?? []; - if (value.length) - validateSignalValue(TYPE_COLLECTION, value, Array.isArray); - validateCallback(TYPE_COLLECTION, watched, isSyncFunction); - const signals = new Map; - const keys = []; - const itemToKey = new Map; - const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig); - const resolveKey = (item) => itemToKey.get(item) ?? (contentBased ? generateKey(item) : undefined); - const itemFactory = options?.createItem ?? ((item) => createState(item, { - equals: options?.itemEquals ?? DEEP_EQUALITY - })); - function buildValue() { - const result = []; - for (const key of keys) { - try { - const v = signals.get(key)?.get(); - if (v != null) - result.push(v); - } catch (e) { - if (!(e instanceof UnsetSignalValueError)) - throw e; - } - } - return result; - } - const node = { - fn: buildValue, - value, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: SKIP_EQUALITY, - error: undefined - }; - for (const item of value) { - const key = generateKey(item); - signals.set(key, itemFactory(item)); - itemToKey.set(item, key); - keys.push(key); - } - node.value = value; - node.flags = FLAG_DIRTY; - const onChanges = (changes) => { - const { add, change, remove } = changes; - if (!add?.length && !change?.length && !remove?.length) - return; - let structural = false; - batch(() => { - if (add) { - const staged = new Map; - for (const item of add) { - const key = generateKey(item); - if (signals.has(key) || staged.has(key)) - throw new DuplicateKeyError(TYPE_COLLECTION, key, item); - staged.set(key, item); - } - for (const [key, item] of staged) { - signals.set(key, itemFactory(item)); - itemToKey.set(item, key); - if (!keys.includes(key)) - keys.push(key); - structural = true; - } - } - if (change) { - for (const item of change) { - const key = resolveKey(item); - if (!key) - continue; - const signal = signals.get(key); - if (signal && isState(signal)) { - itemToKey.delete(untrack(() => signal.get())); - signal.set(item); - itemToKey.set(item, key); - } - } - } - if (remove) { - for (const item of remove) { - const key = resolveKey(item); - if (!key) - continue; - itemToKey.delete(item); - signals.delete(key); - const index = keys.indexOf(key); - if (index !== -1) - keys.splice(index, 1); - structural = true; - } - } - node.flags = FLAG_DIRTY | (structural ? FLAG_RELINK : 0); - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - }); - }; - const subscribe = makeSubscribe(node, () => watched(onChanges)); - const collection = { - [Symbol.toStringTag]: TYPE_COLLECTION, - [Symbol.isConcatSpreadable]: true, - *[Symbol.iterator]() { - subscribe(); - for (const key of keys) { - const signal = signals.get(key); - if (signal) - yield signal; - } - }, - get length() { - subscribe(); - return keys.length; - }, - keys() { - subscribe(); - return keys.values(); - }, - get() { - subscribe(); - if (node.sources) { - if (node.flags) { - const relink = node.flags & FLAG_RELINK; - node.value = untrack(buildValue); - if (relink) { - node.flags = FLAG_DIRTY; - refresh(node); - if (node.error) - throw node.error; - } else { - node.flags = FLAG_CLEAN; - } - } - } else { - refresh(node); - if (node.error) - throw node.error; - } - return node.value; - }, - at(index) { - subscribe(); - const key = keys[index]; - return key !== undefined ? signals.get(key) : undefined; - }, - byKey(key) { - subscribe(); - return signals.get(key); - }, - keyAt(index) { - subscribe(); - return keys[index]; - }, - indexOfKey(key) { - subscribe(); - return keys.indexOf(key); - }, - deriveCollection(cb) { - return deriveCollection(collection, cb); - } - }; - return collection; -} -function isCollection(value) { - return isSignalOfType(value, TYPE_COLLECTION); -} -// src/nodes/effect.ts -function createEffect(fn) { - validateCallback("Effect", fn); - const node = { - fn, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - cleanup: null - }; - const dispose = () => { - runCleanup(node); - node.fn = undefined; - node.flags = FLAG_CLEAN; - node.sourcesTail = null; - trimSources(node); - }; - if (activeOwner) - registerCleanup(activeOwner, dispose); - runEffect(node); - scheduleEffect(node); - return dispose; -} -function match(signalOrSignals, handlers) { - if (!activeOwner) - throw new RequiredOwnerError("match"); - const isSingle = !Array.isArray(signalOrSignals); - const signals = isSingle ? [signalOrSignals] : signalOrSignals; - const { nil, stale } = handlers; - const ok = isSingle ? (values2) => handlers.ok(values2[0]) : (values2) => handlers.ok(values2); - const err = isSingle && handlers.err ? (errors2) => handlers.err(errors2[0]) : handlers.err ?? console.error; - let errors; - let pending = false; - const values = new Array(signals.length); - for (let i = 0;i < signals.length; i++) { - try { - values[i] = signals[i].get(); - } catch (e) { - if (e instanceof UnsetSignalValueError) { - pending = true; - continue; - } - if (!errors) - errors = []; - errors.push(e instanceof Error ? e : new Error(String(e))); - } - } - let out; - try { - if (pending) - out = nil?.(); - else if (errors) - out = err(errors); - else if (stale && (isSingle ? isTask(signals[0]) && signals[0].isPending() : signals.some((s) => isTask(s) && s.isPending()))) - out = stale(); - else - out = ok(values); - } catch (e) { - out = err([e instanceof Error ? e : new Error(String(e))]); - } - if (typeof out === "function") - return out; - if (out instanceof Promise) { - const owner = activeOwner; - const controller = new AbortController; - registerCleanup(owner, () => controller.abort()); - out.then((cleanup) => { - if (!controller.signal.aborted && typeof cleanup === "function") - registerCleanup(owner, cleanup); - }).catch((e) => { - err([e instanceof Error ? e : new Error(String(e))]); - }); - } -} -// src/nodes/sensor.ts -function createSensor(watched, options) { - validateCallback(TYPE_SENSOR, watched, isSyncFunction); - if (options?.value !== undefined) - validateSignalValue(TYPE_SENSOR, options.value, options?.guard); - const node = { - value: options?.value, - sinks: null, - sinksTail: null, - equals: options?.equals ?? DEFAULT_EQUALITY, - guard: options?.guard, - stop: undefined - }; - return { - [Symbol.toStringTag]: TYPE_SENSOR, - get() { - if (activeSink) { - if (!node.sinks) - node.stop = watched((next) => { - validateSignalValue(TYPE_SENSOR, next, node.guard); - setState(node, next); - }); - link(node, activeSink); - } - validateReadValue(TYPE_SENSOR, node.value); - return node.value; - } - }; -} -function isSensor(value) { - return isSignalOfType(value, TYPE_SENSOR); -} -// src/nodes/store.ts -function diffRecords(prev, next) { - const add = {}; - const change = {}; - const remove = {}; - let changed = false; - const prevKeys = Object.keys(prev); - const nextKeys = Object.keys(next); - for (const key of nextKeys) { - if (key in prev) { - if (!DEEP_EQUALITY(prev[key], next[key])) { - change[key] = next[key]; - changed = true; - } - } else { - add[key] = next[key]; - changed = true; - } - } - for (const key of prevKeys) { - if (!(key in next)) { - remove[key] = undefined; - changed = true; - } - } - return { add, change, remove, changed }; -} -function createStore(value, options) { - validateSignalValue(TYPE_STORE, value, isRecord); - const signals = new Map; - const addSignal = (key, val) => { - validateSignalValue(`${TYPE_STORE} for key "${key}"`, val); - if (Array.isArray(val)) - signals.set(key, createList(val)); - else if (isRecord(val)) - signals.set(key, createStore(val)); - else - signals.set(key, createState(val)); - }; - const shapeCategory = (val) => { - if (Array.isArray(val)) - return "list"; - if (isRecord(val)) - return "store"; - return "state"; - }; - const signalCategory = (signal) => { - if (isList(signal)) - return "list"; - if (isStore(signal)) - return "store"; - return "state"; - }; - const buildValue = () => { - const record = {}; - for (const [key, signal] of signals) - record[key] = signal.get(); - return record; - }; - const node = { - fn: buildValue, - value, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: DEEP_EQUALITY, - error: undefined - }; - const applyChanges = (changes) => { - let structural = false; - for (const key in changes.add) { - addSignal(key, changes.add[key]); - structural = true; - } - let hasChange = false; - for (const _key in changes.change) { - hasChange = true; - break; - } - if (hasChange) { - batch(() => { - for (const key in changes.change) { - const val = changes.change[key]; - validateSignalValue(`${TYPE_STORE} for key "${key}"`, val); - const signal = signals.get(key); - if (signal) { - if (shapeCategory(val) !== signalCategory(signal)) { - addSignal(key, val); - structural = true; - } else - signal.set(val); - } - } - }); - } - for (const key in changes.remove) { - signals.delete(key); - structural = true; - } - if (structural) - node.flags |= FLAG_RELINK; - return changes.changed; - }; - const subscribe = makeSubscribe(node, options?.watched); - for (const key of Object.keys(value)) - addSignal(key, value[key]); - const store = { - [Symbol.toStringTag]: TYPE_STORE, - [Symbol.isConcatSpreadable]: false, - *[Symbol.iterator]() { - subscribe(); - for (const [key, signal] of signals) { - yield [key, signal]; - } - }, - keys() { - subscribe(); - return signals.keys(); - }, - byKey(key) { - return signals.get(key); - }, - get() { - subscribe(); - if (node.sources) { - if (node.flags) { - const relink = node.flags & FLAG_RELINK; - node.value = untrack(buildValue); - if (relink) { - node.flags = FLAG_DIRTY; - refresh(node); - if (node.error) - throw node.error; - } else { - node.flags = FLAG_CLEAN; - } - } - } else { - refresh(node); - if (node.error) - throw node.error; - } - return node.value; - }, - set(next) { - const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value; - const changes = diffRecords(prev, next); - if (applyChanges(changes)) { - node.flags |= FLAG_DIRTY; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - } - }, - update(fn) { - store.set(fn(untrack(() => store.get()))); - }, - add(key, value2) { - if (signals.has(key)) - throw new DuplicateKeyError(TYPE_STORE, key, value2); - addSignal(key, value2); - node.flags |= FLAG_DIRTY | FLAG_RELINK; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - return key; - }, - remove(key) { - const ok = signals.delete(key); - if (ok) { - node.flags |= FLAG_DIRTY | FLAG_RELINK; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - } - } - }; - return new Proxy(store, { - get(target, prop) { - if (prop in target) - return Reflect.get(target, prop); - if (typeof prop !== "symbol") - return target.byKey(prop); - }, - set(_target, prop) { - throw new InvalidStoreMutationError(String(prop), "assign to"); - }, - deleteProperty(_target, prop) { - throw new InvalidStoreMutationError(String(prop), "delete"); - }, - defineProperty(_target, prop) { - throw new InvalidStoreMutationError(String(prop), "define"); - }, - has(target, prop) { - if (prop in target) - return true; - return target.byKey(String(prop)) !== undefined; - }, - ownKeys(target) { - return Array.from(target.keys()); - }, - getOwnPropertyDescriptor(target, prop) { - if (prop in target) - return Reflect.getOwnPropertyDescriptor(target, prop); - if (typeof prop === "symbol") - return; - const signal = target.byKey(String(prop)); - return signal ? { - enumerable: true, - configurable: true, - writable: true, - value: signal - } : undefined; - } - }); -} -function isStore(value) { - return isSignalOfType(value, TYPE_STORE); -} - -// src/signal.ts -var SIGNAL_TYPES = new Set([ - TYPE_STATE, - TYPE_MEMO, - TYPE_TASK, - TYPE_SENSOR, - TYPE_SLOT, - TYPE_LIST, - TYPE_COLLECTION, - TYPE_STORE -]); -function createComputed(callback, options) { - return isAsyncFunction(callback) ? createTask(callback, options) : createMemo(callback, options); -} -function createSignal(value) { - if (isSignal(value)) - return value; - if (value == null) - throw new InvalidSignalValueError("createSignal", value); - if (isAsyncFunction(value)) - return createTask(value); - if (isFunction(value)) - return createMemo(value); - if (Array.isArray(value) && value.every((item) => item != null)) - return createList(value); - if (isRecord(value)) - return createStore(value); - return createState(value); -} -function createMutableSignal(value) { - if (isMutableSignal(value)) - return value; - if (value == null || isFunction(value) || isSignal(value)) - throw new InvalidSignalValueError("createMutableSignal", value); - if (Array.isArray(value) && value.every((item) => item != null)) - return createList(value); - if (isRecord(value)) - return createStore(value); - return createState(value); -} -function isComputed(value) { - return isMemo(value) || isTask(value); -} -function isSignal(value) { - return value != null && SIGNAL_TYPES.has(value[Symbol.toStringTag]); -} -function isMutableSignal(value) { - return isState(value) || isStore(value) || isList(value); -} - -// src/nodes/slot.ts -var settingSlots = new WeakSet; -function isSignalOrDescriptor(value) { - if (isSignal(value)) - return true; - return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function"; -} -function createSlot(initialSignal, options) { - validateSignalValue(TYPE_SLOT, initialSignal, isSignalOrDescriptor); - let delegated = initialSignal; - const guard = options?.guard; - const node = { - fn: () => delegated.get(), - value: undefined, - flags: FLAG_DIRTY, - sources: null, - sourcesTail: null, - sinks: null, - sinksTail: null, - equals: options?.equals ?? DEFAULT_EQUALITY, - error: undefined - }; - const get = () => { - if (activeSink) - link(node, activeSink); - refresh(node); - if (node.error) - throw node.error; - return node.value; - }; - const set = (next) => { - if (settingSlots.has(node)) - throw new Error("[Slot] Circular delegation detected in set()"); - settingSlots.add(node); - try { - if (isSlot(delegated)) - return void delegated.set(next); - if ("set" in delegated && typeof delegated.set === "function") { - validateSignalValue(TYPE_SLOT, next, guard); - delegated.set(next); - } else { - throw new ReadonlySignalError(TYPE_SLOT); - } - } finally { - settingSlots.delete(node); - } - }; - const replace = (next) => { - validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor); - delegated = next; - node.flags |= FLAG_DIRTY; - for (let e = node.sinks;e; e = e.nextSink) - propagate(e.sink); - if (batchDepth === 0) - flush(); - }; - return { - [Symbol.toStringTag]: TYPE_SLOT, - configurable: true, - enumerable: true, - get, - set, - replace, - current: () => delegated - }; -} -function isSlot(value) { - return isSignalOfType(value, TYPE_SLOT); -} -export { - valueString, - untrack, - unown, - match, - isTask, - isStore, - isState, - isSlot, - isSignalOfType, - isSignal, - isSensor, - isRecord, - isObjectOfType, - isMutableSignal, - isMemo, - isList, - isFunction, - isEqual, - isComputed, - isCollection, - isAsyncFunction, - createTask, - createStore, - createState, - createSlot, - createSignal, - createSensor, - createScope, - createMutableSignal, - createMemo, - createList, - createEffect, - createComputed, - createCollection, - batch, - UnsetSignalValueError, - SKIP_EQUALITY, - RequiredOwnerError, - ReadonlySignalError, - PromiseValueError, - NullishSignalValueError, - InvalidStoreMutationError, - InvalidSignalValueError, - InvalidCallbackError, - EffectConvergenceError, - DuplicateKeyError, - DEFAULT_EQUALITY, - DEEP_EQUALITY, - CircularDependencyError -}; diff --git a/index.js b/index.js index 482ef63..eee51ae 100644 --- a/index.js +++ b/index.js @@ -1 +1,1872 @@ -var iz=Object.getPrototypeOf(async()=>{});function c(z){return typeof z==="function"}function Zz(z){return c(z)&&Object.getPrototypeOf(z)===iz}function Bz(z){return c(z)&&Object.getPrototypeOf(z)!==iz}function az(z,J){return Object.prototype.toString.call(z)===`[object ${J}]`}function f(z,J){return z!=null&&z[Symbol.toStringTag]===J}function k(z){return z!==null&&typeof z==="object"&&Object.getPrototypeOf(z)===Object.prototype}function xz(z){if(typeof z==="string")return`"${z}"`;if(z!=null&&typeof z==="object")try{return JSON.stringify(z)}catch{return String(z)}return String(z)}class Cz extends Error{constructor(z){super(`[${z}] Circular dependency detected`);this.name="CircularDependencyError"}}class Fz extends Error{constructor(z){super(`[Effect] Effects did not settle after ${z} flush passes — check for effects that write to signals they depend on`);this.name="EffectConvergenceError"}}class Pz extends TypeError{constructor(z){super(`[${z}] Signal value cannot be null or undefined`);this.name="NullishSignalValueError"}}class $z extends Error{constructor(z){super(`[${z}] Signal value is unset`);this.name="UnsetSignalValueError"}}class Hz extends TypeError{constructor(z,J){super(`[${z}] Signal value ${xz(J)} is invalid`);this.name="InvalidSignalValueError"}}class Tz extends TypeError{constructor(z,J){super(`[${z}] Callback ${xz(J)} is invalid`);this.name="InvalidCallbackError"}}class Yz extends Error{constructor(z){super(`[${z}] Signal is read-only`);this.name="ReadonlySignalError"}}class Iz extends Error{constructor(z){super(`[${z}] Active owner is required`);this.name="RequiredOwnerError"}}class mz extends TypeError{constructor(z){super(`[${z}] Callback returned a Promise — use an async callback to create a Task instead`);this.name="PromiseValueError"}}class d extends Error{constructor(z,J,Z){super(`[${z}] Could not add key "${J}"${Z!=null?` with value ${JSON.stringify(Z)}`:""} because it already exists`);this.name="DuplicateKeyError"}}class Qz extends TypeError{constructor(z,J){let Z=J==="delete"?`use store.remove(${JSON.stringify(z)})`:`use store.${z}.set(value), store.set(next), or store.add(key, value)`;super(`[Store] Cannot ${J} property "${z}" directly — ${Z}`);this.name="InvalidStoreMutationError"}}function O(z,J,Z){if(J==null)throw new Pz(z);if(Z&&!Z(J))throw new Hz(z,J)}function qz(z,J){if(J==null)throw new $z(z)}function h(z,J,Z=c){if(!Z(J))throw new Tz(z,J)}var n="State",l="Memo",a="Task",e="Sensor",L="List",i="Collection",zz="Store",Jz="Slot",S=0,r=1,N=2,Xz=4,b=8,G=null,T=null,Mz=[],x=0,Ez=!1,p=(z,J)=>z===J,Sz=(z,J)=>!1,ez=(z,J)=>hz(z,J,new WeakSet),hz=(z,J,Z)=>{if(Object.is(z,J))return!0;if(typeof z!==typeof J)return!1;if(z==null||typeof z!=="object"||J==null||typeof J!=="object")return!1;if(Z.has(z))return!0;Z.add(z);try{let $=Array.isArray(z);if($!==Array.isArray(J))return!1;if($){let B=z,U=J;if(B.length!==U.length)return!1;for(let D=0;Dez(z,J),zJ=s;function JJ(z,J){let Z=J.sourcesTail;if(Z){let $=J.sources;while($){if($===z)return!0;if($===Z)break;$=$.nextSource}}return!1}function _(z,J){let Z=J.sourcesTail;if(Z?.source===z)return;let $=null,B=J.flags&Xz;if(B){if($=Z?Z.nextSource:J.sources,$?.source===z){J.sourcesTail=$;return}}let U=z.sinksTail;if(U?.sink===J&&(!B||JJ(U,J)))return;let D={source:z,sink:J,nextSource:$,prevSink:U,nextSink:null};if(J.sourcesTail=z.sinksTail=D,Z)Z.nextSource=D;else J.sources=D;if(U)U.nextSink=D;else z.sinks=D}function ZJ(z){let{source:J,nextSource:Z,nextSink:$,prevSink:B}=z;if($)$.prevSink=B;else J.sinksTail=B;if(B)B.nextSink=$;else J.sinks=$;if(!J.sinks){if(J.stop)J.stop(),J.stop=void 0;if("sources"in J&&J.sources){let U=J;U.sourcesTail=null,Uz(U),U.flags|=N}}return Z}function Uz(z){let J=z.sourcesTail,Z=J?J.nextSource:z.sources;while(Z)Z=ZJ(Z);if(J)J.nextSource=null;else z.sources=null}function w(z,J=N){let Z=z.flags;if("sinks"in z){if((Z&(N|r))>=J)return;if(z.flags=Z|J,"controller"in z&&z.controller)z.controller.abort(),z.controller=void 0;for(let $=z.sinks;$;$=$.nextSink)w($.sink,r)}else{if((Z&(N|r))>=J)return;let $=Z&(N|r);if(z.flags=Z&Xz|J,!$)Mz.push(z)}}function g(z,J){if(z.equals(z.value,J))return;z.value=J;for(let Z=z.sinks;Z;Z=Z.nextSink)w(Z.sink);if(x===0)F()}function jz(z,J){if(!z.cleanup)z.cleanup=J;else if(Array.isArray(z.cleanup))z.cleanup.push(J);else z.cleanup=[z.cleanup,J]}function Az(z){if(!z.cleanup)return;if(Array.isArray(z.cleanup))for(let J=0;J{if(J.signal.aborted)return;z.controller=void 0,u(()=>{if(z.error||!z.equals(B,z.value)){z.value=B,z.error=void 0;for(let U=z.sinks;U;U=U.nextSink)w(U.sink)}g(z.pendingNode,!1)})},(B)=>{if(J.signal.aborted)return;z.controller=void 0;let U=B instanceof Error?B:Error(String(B));u(()=>{if(!z.error||U.name!==z.error.name||U.message!==z.error.message){z.error=U;for(let D=z.sinks;D;D=D.nextSink)w(D.sink)}g(z.pendingNode,!1)})}),z.flags=S}function yz(z){Az(z);let J=G,Z=T;G=T=z,z.sourcesTail=null,z.flags=Xz;try{let $=z.fn();if(typeof $==="function")jz(z,$)}finally{G=J,T=Z,Uz(z),z.flags&=N|r}}function A(z){if(z.flags&r)for(let J=z.sources;J;J=J.nextSource){if("fn"in J.source)A(J.source);if(z.flags&N)break}if(z.flags&Xz)throw new Cz("controller"in z?a:("value"in z)?l:"Effect");if(z.flags&N)if("controller"in z)jJ(z);else if("value"in z)$J(z);else yz(z);else z.flags=S}var sz=1000;function F(){if(Ez)return;Ez=!0;let z,J=0;try{while(Mz.length>0){if(++J>sz){if(Mz.length=0,!z)z=[];z.push(new Fz(sz));break}let Z=Mz.slice();Mz.length=0;for(let $=0;$Az($);try{let U=z();if(typeof U==="function")jz($,U);return B}finally{if(T=Z,!J?.root&&Z)jz(Z,B)}}function WJ(z){let J=T;T=null;try{return z()}finally{T=J}}function v(z,J){return J?()=>{if(G){if(!z.sinks)z.stop=J();_(z,G)}}:()=>{if(G)_(z,G)}}function t(z,J){O(n,z,J?.guard);let Z={value:z,sinks:null,sinksTail:null,equals:J?.equals??p,guard:J?.guard};return{[Symbol.toStringTag]:n,get(){if(G)_(Z,G);return Z.value},set($){O(n,$,Z.guard),g(Z,$)},update($){h(n,$);let B=$(Z.value);O(n,B,Z.guard),g(Z,B)}}}function Rz(z){return f(z,n)}function fz(z,J){if(z.length!==J.length)return!1;for(let Z=0;Z`${z}${J++}`:Z?($)=>z($)||String(J++):()=>String(J++),Z]}function BJ(z,J,Z,$,B){let U={},D={},V={},P=[],q=!1,K=Math.min(z.length,J.length);for(let X=0;Xt(j,{equals:D})),P=()=>{let j=[];for(let H of $){let W=Z.get(H)?.get();if(W!==void 0)j.push(W)}return j},q={fn:P,value:z,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},K=(j)=>{let H=!1;for(let M in j.add){let R=j.add[M];O(`${L} item for key "${M}"`,R),Z.set(M,V(R)),H=!0}let W=!1;for(let M in j.change){W=!0;break}if(W)u(()=>{for(let M in j.change){let R=j.change[M];O(`${L} item for key "${M}"`,R);let y=Z.get(M);if(y)y.set(R)}});for(let M in j.remove){Z.delete(M);let R=$.indexOf(M);if(R!==-1)$.splice(R,1);H=!0}if(H)q.flags|=b;return j.changed},X=v(q,J?.watched);for(let j=0;jQ.get())))},at(j){X();let H=$[j];return H!==void 0?Z.get(H):void 0},keys(){return X(),$.values()},byKey(j){return X(),Z.get(j)},keyAt(j){return X(),$[j]},indexOfKey(j){return X(),$.indexOf(j)},add(j){let H=B(j);if(Z.has(H))throw new d(L,H,j);$.push(H),O(`${L} item for key "${H}"`,j),Z.set(H,V(j)),q.flags|=N|b;for(let W=q.sinks;W;W=W.nextSink)w(W.sink);if(x===0)F();return H},remove(j){let H=typeof j==="number"?$[j]:j;if(H===void 0)return;if(Z.delete(H)){let M=typeof j==="number"?j:$.indexOf(H);if(M>=0)$.splice(M,1);q.flags|=N|b;for(let R=q.sinks;R;R=R.nextSink)w(R.sink);if(x===0)F()}},replace(j,H){let W=Z.get(j);if(!W)return;if(O(`${L} item for key "${j}"`,H),D(Y(()=>W.get()),H))return;if(u(()=>{W.set(H),q.flags|=N;for(let M=q.sinks;M;M=M.nextSink)w(M.sink)}),x===0)F()},sort(j){let H=[];Y(()=>{for(let M of $){let R=Z.get(M)?.get();if(R!==void 0)H.push([M,R])}}),H.sort(c(j)?(M,R)=>j(M[1],R[1]):(M,R)=>String(M[1]).localeCompare(String(R[1])));let W=[];for(let[M]of H)W.push(M);if(!fz($,W)){$=W,q.flags|=N;for(let M=q.sinks;M;M=M.nextSink)w(M.sink);if(x===0)F()}},splice(j,H,...W){let M=$.length,R=j<0?Math.max(0,M+j):Math.min(j,M),y=Math.max(0,Math.min(H??Math.max(0,M-Math.max(0,R)),M-R)),Wz={},C={},I=!1;Y(()=>{for(let E=0;E$(()=>{if(w(Z),x===0)F()}):void 0);return{[Symbol.toStringTag]:l,get(){if(B(),A(Z),Z.error)throw Z.error;return qz(l,Z.value),Z.value}}}function kz(z){return f(z,l)}function Dz(z,J){if(h(a,z,Zz),J?.value!==void 0)O(a,J.value,J?.guard);let Z={value:!1,sinks:null,sinksTail:null,equals:p},$={fn:z,value:J?.value,sources:null,sourcesTail:null,sinks:null,sinksTail:null,flags:N,equals:J?.equals??p,controller:void 0,error:void 0,stop:void 0,pendingNode:Z},B=J?.watched,U=v($,B?()=>B(()=>{if(w($),x===0)F()}):void 0),D=v(Z);return{[Symbol.toStringTag]:a,get(){if(U(),A($),$.error)throw $.error;return qz(a,$.value),$.value},isPending(){return D(),$.pendingNode.value},abort(){$.controller?.abort(),$.controller=void 0,g($.pendingNode,!1)}}}function Gz(z){return f(z,a)}function _z(z,J){h(i,J);let Z=Zz(J),$=new Map,B=[],U=(j)=>{let H=Z?Dz(async(W,M)=>{let R=Y(()=>z.byKey(j));if(!R)return W;let y=R.get();if(y==null)return W;return J(y,M)}):Nz(()=>{let W=Y(()=>z.byKey(j));if(!W)return;let M=W.get();if(M==null)return;return J(M)});$.set(j,H)};function D(j){if(!fz(B,j)){let H=new Set(j);for(let W of B)if(!H.has(W))$.delete(W);for(let W of j)if(!$.has(W))U(W);B=j,q.flags|=b}}function V(){D(Array.from(z.keys()));let j=[];for(let H of B)try{let W=$.get(H)?.get();if(W!=null)j.push(W)}catch(W){if(!(W instanceof $z))throw W}return j}let q={fn:V,value:[],flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:(j,H)=>{if(j.length!==H.length)return!1;for(let W=0;Wz.keys()));for(let j of X)U(j);B=X;let Q={[Symbol.toStringTag]:i,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){if(G)_(q,G);K();for(let j of B){let H=$.get(j);if(H)yield H}},get length(){if(G)_(q,G);return K(),B.length},keys(){if(G)_(q,G);return K(),B.values()},get(){if(G)_(q,G);return K(),q.value},at(j){if(G)_(q,G);K();let H=B[j];return H!==void 0?$.get(H):void 0},byKey(j){if(G)_(q,G);return K(),$.get(j)},keyAt(j){if(G)_(q,G);return K(),B[j]},indexOfKey(j){if(G)_(q,G);return K(),B.indexOf(j)},deriveCollection(j){return _z(Q,j)}};return Q}function QJ(z,J){let Z=J?.value??[];if(Z.length)O(i,Z,Array.isArray);h(i,z,Bz);let $=new Map,B=[],U=new Map,[D,V]=pz(J?.keyConfig),P=(W)=>U.get(W)??(V?D(W):void 0),q=J?.createItem??((W)=>t(W,{equals:J?.itemEquals??s}));function K(){let W=[];for(let M of B)try{let R=$.get(M)?.get();if(R!=null)W.push(R)}catch(R){if(!(R instanceof $z))throw R}return W}let X={fn:K,value:Z,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:Sz,error:void 0};for(let W of Z){let M=D(W);$.set(M,q(W)),U.set(W,M),B.push(M)}X.value=Z,X.flags=N;let Q=(W)=>{let{add:M,change:R,remove:y}=W;if(!M?.length&&!R?.length&&!y?.length)return;let Wz=!1;u(()=>{if(M){let C=new Map;for(let I of M){let m=D(I);if($.has(m)||C.has(m))throw new d(i,m,I);C.set(m,I)}for(let[I,m]of C){if($.set(I,q(m)),U.set(m,I),!B.includes(I))B.push(I);Wz=!0}}if(R)for(let C of R){let I=P(C);if(!I)continue;let m=$.get(I);if(m&&Rz(m))U.delete(Y(()=>m.get())),m.set(C),U.set(C,I)}if(y)for(let C of y){let I=P(C);if(!I)continue;U.delete(C),$.delete(I);let m=B.indexOf(I);if(m!==-1)B.splice(m,1);Wz=!0}X.flags=N|(Wz?b:0);for(let C=X.sinks;C;C=C.nextSink)w(C.sink)})},j=v(X,()=>z(Q)),H={[Symbol.toStringTag]:i,[Symbol.isConcatSpreadable]:!0,*[Symbol.iterator](){j();for(let W of B){let M=$.get(W);if(M)yield M}},get length(){return j(),B.length},keys(){return j(),B.values()},get(){if(j(),X.sources){if(X.flags){let W=X.flags&b;if(X.value=Y(K),W){if(X.flags=N,A(X),X.error)throw X.error}else X.flags=S}}else if(A(X),X.error)throw X.error;return X.value},at(W){j();let M=B[W];return M!==void 0?$.get(M):void 0},byKey(W){return j(),$.get(W)},keyAt(W){return j(),B[W]},indexOfKey(W){return j(),B.indexOf(W)},deriveCollection(W){return _z(H,W)}};return H}function qJ(z){return f(z,i)}function MJ(z){h("Effect",z);let J={fn:z,flags:N,sources:null,sourcesTail:null,cleanup:null},Z=()=>{Az(J),J.fn=void 0,J.flags=S,J.sourcesTail=null,Uz(J)};if(T)jz(T,Z);return yz(J),tz(J),Z}function UJ(z,J){if(!T)throw new Iz("match");let Z=!Array.isArray(z),$=Z?[z]:z,{nil:B,stale:U}=J,D=Z?(Q)=>J.ok(Q[0]):(Q)=>J.ok(Q),V=Z&&J.err?(Q)=>J.err(Q[0]):J.err??console.error,P,q=!1,K=Array($.length);for(let Q=0;Q<$.length;Q++)try{K[Q]=$[Q].get()}catch(j){if(j instanceof $z){q=!0;continue}if(!P)P=[];P.push(j instanceof Error?j:Error(String(j)))}let X;try{if(q)X=B?.();else if(P)X=V(P);else if(U&&(Z?Gz($[0])&&$[0].isPending():$.some((Q)=>Gz(Q)&&Q.isPending())))X=U();else X=D(K)}catch(Q){X=V([Q instanceof Error?Q:Error(String(Q))])}if(typeof X==="function")return X;if(X instanceof Promise){let Q=T,j=new AbortController;jz(Q,()=>j.abort()),X.then((H)=>{if(!j.signal.aborted&&typeof H==="function")jz(Q,H)}).catch((H)=>{V([H instanceof Error?H:Error(String(H))])})}}function VJ(z,J){if(h(e,z,Bz),J?.value!==void 0)O(e,J.value,J?.guard);let Z={value:J?.value,sinks:null,sinksTail:null,equals:J?.equals??p,guard:J?.guard,stop:void 0};return{[Symbol.toStringTag]:e,get(){if(G){if(!Z.sinks)Z.stop=z(($)=>{O(e,$,Z.guard),g(Z,$)});_(Z,G)}return qz(e,Z.value),Z.value}}}function NJ(z){return f(z,e)}function DJ(z,J){let Z={},$={},B={},U=!1,D=Object.keys(z),V=Object.keys(J);for(let P of V)if(P in z){if(!s(z[P],J[P]))$[P]=J[P],U=!0}else Z[P]=J[P],U=!0;for(let P of D)if(!(P in J))B[P]=void 0,U=!0;return{add:Z,change:$,remove:B,changed:U}}function Oz(z,J){O(zz,z,k);let Z=new Map,$=(X,Q)=>{if(O(`${zz} for key "${X}"`,Q),Array.isArray(Q))Z.set(X,Vz(Q));else if(k(Q))Z.set(X,Oz(Q));else Z.set(X,t(Q))},B=(X)=>{if(Array.isArray(X))return"list";if(k(X))return"store";return"state"},U=(X)=>{if(Kz(X))return"list";if(Lz(X))return"store";return"state"},D=()=>{let X={};for(let[Q,j]of Z)X[Q]=j.get();return X},V={fn:D,value:z,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:s,error:void 0},P=(X)=>{let Q=!1;for(let H in X.add)$(H,X.add[H]),Q=!0;let j=!1;for(let H in X.change){j=!0;break}if(j)u(()=>{for(let H in X.change){let W=X.change[H];O(`${zz} for key "${H}"`,W);let M=Z.get(H);if(M)if(B(W)!==U(M))$(H,W),Q=!0;else M.set(W)}});for(let H in X.remove)Z.delete(H),Q=!0;if(Q)V.flags|=b;return X.changed},q=v(V,J?.watched);for(let X of Object.keys(z))$(X,z[X]);let K={[Symbol.toStringTag]:zz,[Symbol.isConcatSpreadable]:!1,*[Symbol.iterator](){q();for(let[X,Q]of Z)yield[X,Q]},keys(){return q(),Z.keys()},byKey(X){return Z.get(X)},get(){if(q(),V.sources){if(V.flags){let X=V.flags&b;if(V.value=Y(D),X){if(V.flags=N,A(V),V.error)throw V.error}else V.flags=S}}else if(A(V),V.error)throw V.error;return V.value},set(X){let Q=V.flags&N?Y(D):V.value,j=DJ(Q,X);if(P(j)){V.flags|=N;for(let H=V.sinks;H;H=H.nextSink)w(H.sink);if(x===0)F()}},update(X){K.set(X(Y(()=>K.get())))},add(X,Q){if(Z.has(X))throw new d(zz,X,Q);$(X,Q),V.flags|=N|b;for(let j=V.sinks;j;j=j.nextSink)w(j.sink);if(x===0)F();return X},remove(X){if(Z.delete(X)){V.flags|=N|b;for(let j=V.sinks;j;j=j.nextSink)w(j.sink);if(x===0)F()}}};return new Proxy(K,{get(X,Q){if(Q in X)return Reflect.get(X,Q);if(typeof Q!=="symbol")return X.byKey(Q)},set(X,Q){throw new Qz(String(Q),"assign to")},deleteProperty(X,Q){throw new Qz(String(Q),"delete")},defineProperty(X,Q){throw new Qz(String(Q),"define")},has(X,Q){if(Q in X)return!0;return X.byKey(String(Q))!==void 0},ownKeys(X){return Array.from(X.keys())},getOwnPropertyDescriptor(X,Q){if(Q in X)return Reflect.getOwnPropertyDescriptor(X,Q);if(typeof Q==="symbol")return;let j=X.byKey(String(Q));return j?{enumerable:!0,configurable:!0,writable:!0,value:j}:void 0}})}function Lz(z){return f(z,zz)}var GJ=new Set([n,l,a,e,Jz,L,i,zz]);function PJ(z,J){return Zz(z)?Dz(z,J):Nz(z,J)}function RJ(z){if(wz(z))return z;if(z==null)throw new Hz("createSignal",z);if(Zz(z))return Dz(z);if(c(z))return Nz(z);if(Array.isArray(z)&&z.every((J)=>J!=null))return Vz(z);if(k(z))return Oz(z);return t(z)}function KJ(z){if(oz(z))return z;if(z==null||c(z)||wz(z))throw new Hz("createMutableSignal",z);if(Array.isArray(z)&&z.every((J)=>J!=null))return Vz(z);if(k(z))return Oz(z);return t(z)}function OJ(z){return kz(z)||Gz(z)}function wz(z){return z!=null&&GJ.has(z[Symbol.toStringTag])}function oz(z){return Rz(z)||Lz(z)||Kz(z)}var gz=new WeakSet;function rz(z){if(wz(z))return!0;return z!==null&&typeof z==="object"&&"get"in z&&typeof z.get==="function"}function wJ(z,J){O(Jz,z,rz);let Z=z,$=J?.guard,B={fn:()=>Z.get(),value:void 0,flags:N,sources:null,sourcesTail:null,sinks:null,sinksTail:null,equals:J?.equals??p,error:void 0},U=()=>{if(G)_(B,G);if(A(B),B.error)throw B.error;return B.value},D=(P)=>{if(gz.has(B))throw Error("[Slot] Circular delegation detected in set()");gz.add(B);try{if(nz(Z))return void Z.set(P);if("set"in Z&&typeof Z.set==="function")O(Jz,P,$),Z.set(P);else throw new Yz(Jz)}finally{gz.delete(B)}},V=(P)=>{O(Jz,P,rz),Z=P,B.flags|=N;for(let q=B.sinks;q;q=q.nextSink)w(q.sink);if(x===0)F()};return{[Symbol.toStringTag]:Jz,configurable:!0,enumerable:!0,get:U,set:D,replace:V,current:()=>Z}}function nz(z){return f(z,Jz)}export{xz as valueString,Y as untrack,WJ as unown,UJ as match,Gz as isTask,Lz as isStore,Rz as isState,nz as isSlot,f as isSignalOfType,wz as isSignal,NJ as isSensor,k as isRecord,az as isObjectOfType,oz as isMutableSignal,kz as isMemo,Kz as isList,c as isFunction,zJ as isEqual,OJ as isComputed,qJ as isCollection,Zz as isAsyncFunction,Dz as createTask,Oz as createStore,t as createState,wJ as createSlot,RJ as createSignal,VJ as createSensor,XJ as createScope,KJ as createMutableSignal,Nz as createMemo,Vz as createList,MJ as createEffect,PJ as createComputed,QJ as createCollection,u as batch,$z as UnsetSignalValueError,Sz as SKIP_EQUALITY,Iz as RequiredOwnerError,Yz as ReadonlySignalError,mz as PromiseValueError,Pz as NullishSignalValueError,Qz as InvalidStoreMutationError,Hz as InvalidSignalValueError,Tz as InvalidCallbackError,Fz as EffectConvergenceError,d as DuplicateKeyError,p as DEFAULT_EQUALITY,s as DEEP_EQUALITY,Cz as CircularDependencyError}; +// src/util.ts +var ASYNC_FUNCTION_PROTO = Object.getPrototypeOf(async () => {}); +function isFunction(fn) { + return typeof fn === "function"; +} +function isAsyncFunction(fn) { + return isFunction(fn) && Object.getPrototypeOf(fn) === ASYNC_FUNCTION_PROTO; +} +function isSyncFunction(fn) { + return isFunction(fn) && Object.getPrototypeOf(fn) !== ASYNC_FUNCTION_PROTO; +} +function isObjectOfType(value, type) { + return Object.prototype.toString.call(value) === `[object ${type}]`; +} +function isSignalOfType(value, type) { + return value != null && value[Symbol.toStringTag] === type; +} +function isRecord(value) { + return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype; +} +function valueString(value) { + if (typeof value === "string") + return `"${value}"`; + if (value != null && typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +// src/errors.ts +class CircularDependencyError extends Error { + constructor(where) { + super(`[${where}] Circular dependency detected`); + this.name = "CircularDependencyError"; + } +} + +class EffectConvergenceError extends Error { + constructor(passes) { + super(`[Effect] Effects did not settle after ${passes} flush passes — check for effects that write to signals they depend on`); + this.name = "EffectConvergenceError"; + } +} + +class NullishSignalValueError extends TypeError { + constructor(where) { + super(`[${where}] Signal value cannot be null or undefined`); + this.name = "NullishSignalValueError"; + } +} + +class UnsetSignalValueError extends Error { + constructor(where) { + super(`[${where}] Signal value is unset`); + this.name = "UnsetSignalValueError"; + } +} + +class InvalidSignalValueError extends TypeError { + constructor(where, value) { + super(`[${where}] Signal value ${valueString(value)} is invalid`); + this.name = "InvalidSignalValueError"; + } +} + +class InvalidCallbackError extends TypeError { + constructor(where, value) { + super(`[${where}] Callback ${valueString(value)} is invalid`); + this.name = "InvalidCallbackError"; + } +} + +class ReadonlySignalError extends Error { + constructor(where) { + super(`[${where}] Signal is read-only`); + this.name = "ReadonlySignalError"; + } +} + +class RequiredOwnerError extends Error { + constructor(where) { + super(`[${where}] Active owner is required`); + this.name = "RequiredOwnerError"; + } +} + +class PromiseValueError extends TypeError { + constructor(where) { + super(`[${where}] Callback returned a Promise — use an async callback to create a Task instead`); + this.name = "PromiseValueError"; + } +} + +class DuplicateKeyError extends Error { + constructor(where, key, value) { + super(`[${where}] Could not add key "${key}"${value != null ? ` with value ${JSON.stringify(value)}` : ""} because it already exists`); + this.name = "DuplicateKeyError"; + } +} + +class InvalidStoreMutationError extends TypeError { + constructor(prop, action) { + const guidance = action === "delete" ? `use store.remove(${JSON.stringify(prop)})` : `use store.${prop}.set(value), store.set(next), or store.add(key, value)`; + super(`[Store] Cannot ${action} property "${prop}" directly — ${guidance}`); + this.name = "InvalidStoreMutationError"; + } +} +function validateSignalValue(where, value, guard) { + if (value == null) + throw new NullishSignalValueError(where); + if (guard && !guard(value)) + throw new InvalidSignalValueError(where, value); +} +function validateReadValue(where, value) { + if (value == null) + throw new UnsetSignalValueError(where); +} +function validateCallback(where, value, guard = isFunction) { + if (!guard(value)) + throw new InvalidCallbackError(where, value); +} +// src/graph.ts +var TYPE_STATE = "State"; +var TYPE_MEMO = "Memo"; +var TYPE_TASK = "Task"; +var TYPE_SENSOR = "Sensor"; +var TYPE_LIST = "List"; +var TYPE_COLLECTION = "Collection"; +var TYPE_STORE = "Store"; +var TYPE_SLOT = "Slot"; +var FLAG_CLEAN = 0; +var FLAG_CHECK = 1 << 0; +var FLAG_DIRTY = 1 << 1; +var FLAG_RUNNING = 1 << 2; +var FLAG_RELINK = 1 << 3; +var activeSink = null; +var activeOwner = null; +var queuedEffects = []; +var batchDepth = 0; +var flushing = false; +var DEFAULT_EQUALITY = (a, b) => a === b; +var SKIP_EQUALITY = (_a, _b) => false; +var deepEqual = (a, b) => deepEqualInner(a, b, new WeakSet); +var deepEqualInner = (a, b, seen) => { + if (Object.is(a, b)) + return true; + if (typeof a !== typeof b) + return false; + if (a == null || typeof a !== "object" || b == null || typeof b !== "object") + return false; + if (seen.has(a)) + return true; + seen.add(a); + try { + const aIsArray = Array.isArray(a); + if (aIsArray !== Array.isArray(b)) + return false; + if (aIsArray) { + const aa = a; + const ba = b; + if (aa.length !== ba.length) + return false; + for (let i = 0;i < aa.length; i++) + if (!deepEqualInner(aa[i], ba[i], seen)) + return false; + return true; + } + if (a instanceof Date && b instanceof Date) + return a.getTime() === b.getTime(); + if (a instanceof RegExp && b instanceof RegExp) + return a.source === b.source && a.flags === b.flags; + if (isRecord(a) && isRecord(b)) { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) + return false; + for (const key of aKeys) { + if (!(key in b)) + return false; + if (!deepEqualInner(a[key], b[key], seen)) + return false; + } + return true; + } + return false; + } finally { + seen.delete(a); + } +}; +var DEEP_EQUALITY = (a, b) => deepEqual(a, b); +var isEqual = DEEP_EQUALITY; +function isValidEdge(checkEdge, node) { + const sourcesTail = node.sourcesTail; + if (sourcesTail) { + let edge = node.sources; + while (edge) { + if (edge === checkEdge) + return true; + if (edge === sourcesTail) + break; + edge = edge.nextSource; + } + } + return false; +} +function link(source, sink) { + const prevSource = sink.sourcesTail; + if (prevSource?.source === source) + return; + let nextSource = null; + const isRecomputing = sink.flags & FLAG_RUNNING; + if (isRecomputing) { + nextSource = prevSource ? prevSource.nextSource : sink.sources; + if (nextSource?.source === source) { + sink.sourcesTail = nextSource; + return; + } + } + const prevSink = source.sinksTail; + if (prevSink?.sink === sink && (!isRecomputing || isValidEdge(prevSink, sink))) + return; + const newEdge = { source, sink, nextSource, prevSink, nextSink: null }; + sink.sourcesTail = source.sinksTail = newEdge; + if (prevSource) + prevSource.nextSource = newEdge; + else + sink.sources = newEdge; + if (prevSink) + prevSink.nextSink = newEdge; + else + source.sinks = newEdge; +} +function unlink(edge) { + const { source, nextSource, nextSink, prevSink } = edge; + if (nextSink) + nextSink.prevSink = prevSink; + else + source.sinksTail = prevSink; + if (prevSink) + prevSink.nextSink = nextSink; + else + source.sinks = nextSink; + if (!source.sinks) { + if (source.stop) { + source.stop(); + source.stop = undefined; + } + if ("sources" in source && source.sources) { + const sinkNode = source; + sinkNode.sourcesTail = null; + trimSources(sinkNode); + sinkNode.flags |= FLAG_DIRTY; + } + } + return nextSource; +} +function trimSources(node) { + const tail = node.sourcesTail; + let source = tail ? tail.nextSource : node.sources; + while (source) + source = unlink(source); + if (tail) + tail.nextSource = null; + else + node.sources = null; +} +function propagate(node, newFlag = FLAG_DIRTY) { + const flags = node.flags; + if ("sinks" in node) { + if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) + return; + node.flags = flags | newFlag; + if ("controller" in node && node.controller) { + node.controller.abort(); + node.controller = undefined; + } + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink, FLAG_CHECK); + } else { + if ((flags & (FLAG_DIRTY | FLAG_CHECK)) >= newFlag) + return; + const wasQueued = flags & (FLAG_DIRTY | FLAG_CHECK); + node.flags = flags & FLAG_RUNNING | newFlag; + if (!wasQueued) + queuedEffects.push(node); + } +} +function setState(node, next) { + if (node.equals(node.value, next)) + return; + node.value = next; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); +} +function registerCleanup(owner, fn) { + if (!owner.cleanup) + owner.cleanup = fn; + else if (Array.isArray(owner.cleanup)) + owner.cleanup.push(fn); + else + owner.cleanup = [owner.cleanup, fn]; +} +function runCleanup(owner) { + if (!owner.cleanup) + return; + if (Array.isArray(owner.cleanup)) + for (let i = 0;i < owner.cleanup.length; i++) + owner.cleanup[i](); + else + owner.cleanup(); + owner.cleanup = null; +} +function recomputeMemo(node) { + const prevWatcher = activeSink; + activeSink = node; + node.sourcesTail = null; + node.flags = FLAG_RUNNING; + let changed = false; + try { + const next = node.fn(node.value); + if (next instanceof Promise) + throw new PromiseValueError(TYPE_MEMO); + if (node.error || !node.equals(next, node.value)) { + node.value = next; + node.error = undefined; + changed = true; + } + } catch (err) { + changed = true; + node.error = err instanceof Error ? err : new Error(String(err)); + } finally { + activeSink = prevWatcher; + trimSources(node); + } + if (changed) { + for (let e = node.sinks;e; e = e.nextSink) + if (e.sink.flags & FLAG_CHECK) + e.sink.flags |= FLAG_DIRTY; + } + node.flags = FLAG_CLEAN; +} +function recomputeTask(node) { + node.controller?.abort(); + const controller = new AbortController; + node.controller = controller; + node.error = undefined; + const prevWatcher = activeSink; + activeSink = node; + node.sourcesTail = null; + node.flags = FLAG_RUNNING; + let promise; + try { + promise = node.fn(node.value, controller.signal); + } catch (err) { + node.controller = undefined; + node.error = err instanceof Error ? err : new Error(String(err)); + node.flags = FLAG_CLEAN; + setState(node.pendingNode, false); + return; + } finally { + activeSink = prevWatcher; + trimSources(node); + } + setState(node.pendingNode, true); + promise.then((next) => { + if (controller.signal.aborted) + return; + node.controller = undefined; + batch(() => { + if (node.error || !node.equals(next, node.value)) { + node.value = next; + node.error = undefined; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + } + setState(node.pendingNode, false); + }); + }, (err) => { + if (controller.signal.aborted) + return; + node.controller = undefined; + const error = err instanceof Error ? err : new Error(String(err)); + batch(() => { + if (!node.error || error.name !== node.error.name || error.message !== node.error.message) { + node.error = error; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + } + setState(node.pendingNode, false); + }); + }); + node.flags = FLAG_CLEAN; +} +function runEffect(node) { + runCleanup(node); + const prevContext = activeSink; + const prevOwner = activeOwner; + activeSink = activeOwner = node; + node.sourcesTail = null; + node.flags = FLAG_RUNNING; + try { + const out = node.fn(); + if (typeof out === "function") + registerCleanup(node, out); + } finally { + activeSink = prevContext; + activeOwner = prevOwner; + trimSources(node); + node.flags &= FLAG_DIRTY | FLAG_CHECK; + } +} +function refresh(node) { + if (node.flags & FLAG_CHECK) { + for (let e = node.sources;e; e = e.nextSource) { + if ("fn" in e.source) + refresh(e.source); + if (node.flags & FLAG_DIRTY) + break; + } + } + if (node.flags & FLAG_RUNNING) { + throw new CircularDependencyError("controller" in node ? TYPE_TASK : ("value" in node) ? TYPE_MEMO : "Effect"); + } + if (node.flags & FLAG_DIRTY) { + if ("controller" in node) + recomputeTask(node); + else if ("value" in node) + recomputeMemo(node); + else + runEffect(node); + } else { + node.flags = FLAG_CLEAN; + } +} +var MAX_FLUSH_PASSES = 1000; +function flush() { + if (flushing) + return; + flushing = true; + let errors; + let passes = 0; + try { + while (queuedEffects.length > 0) { + if (++passes > MAX_FLUSH_PASSES) { + queuedEffects.length = 0; + if (!errors) + errors = []; + errors.push(new EffectConvergenceError(MAX_FLUSH_PASSES)); + break; + } + const batch = queuedEffects.slice(); + queuedEffects.length = 0; + for (let i = 0;i < batch.length; i++) { + const effect = batch[i]; + if (effect.flags & FLAG_RUNNING) + continue; + if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) { + try { + refresh(effect); + } catch (err) { + if (!errors) + errors = []; + errors.push(err); + } + } + } + } + } finally { + flushing = false; + } + if (errors) { + if (errors.length === 1) + throw errors[0]; + throw new AggregateError(errors, "Multiple effects threw during flush"); + } +} +function scheduleEffect(node) { + if (node.flags & (FLAG_DIRTY | FLAG_CHECK)) { + queuedEffects.push(node); + if (batchDepth === 0) + flush(); + } +} +function batch(fn) { + batchDepth++; + try { + fn(); + } finally { + batchDepth--; + if (batchDepth === 0) + flush(); + } +} +function untrack(fn) { + const prev = activeSink; + activeSink = null; + try { + return fn(); + } finally { + activeSink = prev; + } +} +function createScope(fn, options) { + const prevOwner = activeOwner; + const scope = { cleanup: null }; + activeOwner = scope; + const dispose = () => runCleanup(scope); + try { + const out = fn(); + if (typeof out === "function") + registerCleanup(scope, out); + return dispose; + } finally { + activeOwner = prevOwner; + if (!options?.root && prevOwner) + registerCleanup(prevOwner, dispose); + } +} +function unown(fn) { + const prev = activeOwner; + activeOwner = null; + try { + return fn(); + } finally { + activeOwner = prev; + } +} +function makeSubscribe(node, onWatch) { + return onWatch ? () => { + if (activeSink) { + if (!node.sinks) + node.stop = onWatch(); + link(node, activeSink); + } + } : () => { + if (activeSink) + link(node, activeSink); + }; +} +// src/nodes/state.ts +function createState(value, options) { + validateSignalValue(TYPE_STATE, value, options?.guard); + const node = { + value, + sinks: null, + sinksTail: null, + equals: options?.equals ?? DEFAULT_EQUALITY, + guard: options?.guard + }; + return { + [Symbol.toStringTag]: TYPE_STATE, + get() { + if (activeSink) + link(node, activeSink); + return node.value; + }, + set(next) { + validateSignalValue(TYPE_STATE, next, node.guard); + setState(node, next); + }, + update(fn) { + validateCallback(TYPE_STATE, fn); + const next = fn(node.value); + validateSignalValue(TYPE_STATE, next, node.guard); + setState(node, next); + } + }; +} +function isState(value) { + return isSignalOfType(value, TYPE_STATE); +} + +// src/nodes/list.ts +function keysEqual(a, b) { + if (a.length !== b.length) + return false; + for (let i = 0;i < a.length; i++) + if (a[i] !== b[i]) + return false; + return true; +} +function getKeyGenerator(keyConfig) { + let keyCounter = 0; + const contentBased = typeof keyConfig === "function"; + return [ + typeof keyConfig === "string" ? () => `${keyConfig}${keyCounter++}` : contentBased ? (item) => keyConfig(item) || String(keyCounter++) : () => String(keyCounter++), + contentBased + ]; +} +function diffPositional(prev, next, prevKeys, generateKey, itemEquals) { + const add = {}; + const change = {}; + const remove = {}; + const nextKeys = []; + let changed = false; + const minLen = Math.min(prev.length, next.length); + for (let i = 0;i < minLen; i++) { + const key = prevKeys[i]; + nextKeys.push(key); + if (!itemEquals(prev[i], next[i])) { + change[key] = next[i]; + changed = true; + } + } + for (let i = minLen;i < next.length; i++) { + const val = next[i]; + const key = generateKey(val); + nextKeys.push(key); + add[key] = val; + changed = true; + } + for (let i = minLen;i < prev.length; i++) { + remove[prevKeys[i]] = null; + changed = true; + } + return { add, change, remove, newKeys: nextKeys, changed }; +} +function diffArrays(prev, next, prevKeys, generateKey, contentBased, itemEquals) { + if (!contentBased) + return diffPositional(prev, next, prevKeys, generateKey, itemEquals); + const add = {}; + const change = {}; + const remove = {}; + const nextKeys = []; + let changed = false; + const prevByKey = new Map; + for (let i = 0;i < prev.length; i++) { + const key = prevKeys[i]; + const item = prev[i]; + if (key && item !== undefined) + prevByKey.set(key, item); + } + const seenKeys = new Set; + for (let i = 0;i < next.length; i++) { + const val = next[i]; + validateSignalValue(`${TYPE_LIST} item at index ${i}`, val); + const key = generateKey(val); + if (seenKeys.has(key)) + throw new DuplicateKeyError(TYPE_LIST, key, val); + nextKeys.push(key); + seenKeys.add(key); + if (!prevByKey.has(key)) { + add[key] = val; + changed = true; + } else if (!itemEquals(prevByKey.get(key), val)) { + change[key] = val; + changed = true; + } + } + for (const [key] of prevByKey) { + if (!seenKeys.has(key)) { + remove[key] = null; + changed = true; + } + } + if (!changed && !keysEqual(prevKeys, nextKeys)) + changed = true; + return { add, change, remove, newKeys: nextKeys, changed }; +} +function createList(value, options) { + validateSignalValue(TYPE_LIST, value, Array.isArray); + const signals = new Map; + let keys = []; + const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig); + const itemEquals = options?.itemEquals ?? DEEP_EQUALITY; + const itemFactory = options?.createItem ?? ((item) => createState(item, { equals: itemEquals })); + const buildValue = () => { + const result = []; + for (const key of keys) { + const v = signals.get(key)?.get(); + if (v !== undefined) + result.push(v); + } + return result; + }; + const node = { + fn: buildValue, + value, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: DEEP_EQUALITY, + error: undefined + }; + const applyChanges = (changes) => { + let structural = false; + for (const key in changes.add) { + const val = changes.add[key]; + validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val); + signals.set(key, itemFactory(val)); + structural = true; + } + let hasChange = false; + for (const _key in changes.change) { + hasChange = true; + break; + } + if (hasChange) { + batch(() => { + for (const key in changes.change) { + const val = changes.change[key]; + validateSignalValue(`${TYPE_LIST} item for key "${key}"`, val); + const signal = signals.get(key); + if (signal) + signal.set(val); + } + }); + } + for (const key in changes.remove) { + signals.delete(key); + const index = keys.indexOf(key); + if (index !== -1) + keys.splice(index, 1); + structural = true; + } + if (structural) + node.flags |= FLAG_RELINK; + return changes.changed; + }; + const subscribe = makeSubscribe(node, options?.watched); + for (let i = 0;i < value.length; i++) { + const val = value[i]; + if (val == null) + throw new NullishSignalValueError(`${TYPE_LIST} item ${i}`); + let key = keys[i]; + if (!key) { + key = generateKey(val); + keys[i] = key; + } + signals.set(key, itemFactory(val)); + } + node.value = value; + node.flags = 0; + const list = { + [Symbol.toStringTag]: TYPE_LIST, + [Symbol.isConcatSpreadable]: true, + *[Symbol.iterator]() { + subscribe(); + for (const key of keys) { + const signal = signals.get(key); + if (signal) + yield signal; + } + }, + get length() { + subscribe(); + return keys.length; + }, + get() { + subscribe(); + if (node.sources) { + if (node.flags) { + const relink = node.flags & FLAG_RELINK; + node.value = untrack(buildValue); + if (relink) { + node.flags = FLAG_DIRTY; + refresh(node); + if (node.error) + throw node.error; + } else { + node.flags = FLAG_CLEAN; + } + } + } else { + refresh(node); + if (node.error) + throw node.error; + } + return node.value; + }, + set(next) { + const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value; + const changes = diffArrays(prev, next, keys, generateKey, contentBased, itemEquals); + if (changes.changed) { + keys = changes.newKeys; + applyChanges(changes); + node.flags |= FLAG_DIRTY; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + } + }, + update(fn) { + list.set(fn(untrack(() => list.get()))); + }, + at(index) { + subscribe(); + const key = keys[index]; + return key !== undefined ? signals.get(key) : undefined; + }, + keys() { + subscribe(); + return keys.values(); + }, + byKey(key) { + subscribe(); + return signals.get(key); + }, + keyAt(index) { + subscribe(); + return keys[index]; + }, + indexOfKey(key) { + subscribe(); + return keys.indexOf(key); + }, + add(value2) { + const key = generateKey(value2); + if (signals.has(key)) + throw new DuplicateKeyError(TYPE_LIST, key, value2); + keys.push(key); + validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value2); + signals.set(key, itemFactory(value2)); + node.flags |= FLAG_DIRTY | FLAG_RELINK; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + return key; + }, + remove(keyOrIndex) { + const key = typeof keyOrIndex === "number" ? keys[keyOrIndex] : keyOrIndex; + if (key === undefined) + return; + const ok = signals.delete(key); + if (ok) { + const index = typeof keyOrIndex === "number" ? keyOrIndex : keys.indexOf(key); + if (index >= 0) + keys.splice(index, 1); + node.flags |= FLAG_DIRTY | FLAG_RELINK; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + } + }, + replace(key, value2) { + const signal = signals.get(key); + if (!signal) + return; + validateSignalValue(`${TYPE_LIST} item for key "${key}"`, value2); + if (itemEquals(untrack(() => signal.get()), value2)) + return; + batch(() => { + signal.set(value2); + node.flags |= FLAG_DIRTY; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + }); + if (batchDepth === 0) + flush(); + }, + sort(compareFn) { + const entries = []; + untrack(() => { + for (const key of keys) { + const v = signals.get(key)?.get(); + if (v !== undefined) + entries.push([key, v]); + } + }); + entries.sort(isFunction(compareFn) ? (a, b) => compareFn(a[1], b[1]) : (a, b) => String(a[1]).localeCompare(String(b[1]))); + const newOrder = []; + for (const [key] of entries) + newOrder.push(key); + if (!keysEqual(keys, newOrder)) { + keys = newOrder; + node.flags |= FLAG_DIRTY; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + } + }, + splice(start, deleteCount, ...items) { + const length = keys.length; + const actualStart = start < 0 ? Math.max(0, length + start) : Math.min(start, length); + const actualDeleteCount = Math.max(0, Math.min(deleteCount ?? Math.max(0, length - Math.max(0, actualStart)), length - actualStart)); + const add = {}; + const remove = {}; + let hasRemove = false; + untrack(() => { + for (let i = 0;i < actualDeleteCount; i++) { + const index = actualStart + i; + const key = keys[index]; + if (key) { + const signal = signals.get(key); + if (signal) { + remove[key] = signal.get(); + hasRemove = true; + } + } + } + }); + const newOrder = keys.slice(0, actualStart); + const change = {}; + let hasAdd = false; + let hasChange = false; + for (const item of items) { + const key = generateKey(item); + if (key in remove) { + delete remove[key]; + change[key] = item; + hasChange = true; + } else if (signals.has(key)) { + throw new DuplicateKeyError(TYPE_LIST, key, item); + } else { + add[key] = item; + hasAdd = true; + } + newOrder.push(key); + } + newOrder.push(...keys.slice(actualStart + actualDeleteCount)); + const changed = hasAdd || hasRemove || hasChange; + if (changed) { + applyChanges({ + add, + change, + remove, + changed + }); + keys = newOrder; + node.flags |= FLAG_DIRTY; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + } + return Object.values(remove); + }, + deriveCollection(cb) { + return deriveCollection(list, cb); + } + }; + return list; +} +function isList(value) { + return isSignalOfType(value, TYPE_LIST); +} + +// src/nodes/memo.ts +function createMemo(fn, options) { + validateCallback(TYPE_MEMO, fn, isSyncFunction); + if (options?.value !== undefined) + validateSignalValue(TYPE_MEMO, options.value, options?.guard); + const node = { + fn, + value: options?.value, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: options?.equals ?? DEFAULT_EQUALITY, + error: undefined, + stop: undefined + }; + const watched = options?.watched; + const subscribe = makeSubscribe(node, watched ? () => watched(() => { + propagate(node); + if (batchDepth === 0) + flush(); + }) : undefined); + return { + [Symbol.toStringTag]: TYPE_MEMO, + get() { + subscribe(); + refresh(node); + if (node.error) + throw node.error; + validateReadValue(TYPE_MEMO, node.value); + return node.value; + } + }; +} +function isMemo(value) { + return isSignalOfType(value, TYPE_MEMO); +} + +// src/nodes/task.ts +function createTask(fn, options) { + validateCallback(TYPE_TASK, fn, isAsyncFunction); + if (options?.value !== undefined) + validateSignalValue(TYPE_TASK, options.value, options?.guard); + const pendingNode = { + value: false, + sinks: null, + sinksTail: null, + equals: DEFAULT_EQUALITY + }; + const node = { + fn, + value: options?.value, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + flags: FLAG_DIRTY, + equals: options?.equals ?? DEFAULT_EQUALITY, + controller: undefined, + error: undefined, + stop: undefined, + pendingNode + }; + const watched = options?.watched; + const subscribe = makeSubscribe(node, watched ? () => watched(() => { + propagate(node); + if (batchDepth === 0) + flush(); + }) : undefined); + const pendingSubscribe = makeSubscribe(pendingNode); + return { + [Symbol.toStringTag]: TYPE_TASK, + get() { + subscribe(); + refresh(node); + if (node.error) + throw node.error; + validateReadValue(TYPE_TASK, node.value); + return node.value; + }, + isPending() { + pendingSubscribe(); + return node.pendingNode.value; + }, + abort() { + node.controller?.abort(); + node.controller = undefined; + setState(node.pendingNode, false); + } + }; +} +function isTask(value) { + return isSignalOfType(value, TYPE_TASK); +} + +// src/nodes/collection.ts +function deriveCollection(source, callback) { + validateCallback(TYPE_COLLECTION, callback); + const isAsync = isAsyncFunction(callback); + const signals = new Map; + let keys = []; + const addSignal = (key) => { + const signal = isAsync ? createTask(async (prev, abort) => { + const itemSignal = untrack(() => source.byKey(key)); + if (!itemSignal) + return prev; + const sourceValue = itemSignal.get(); + if (sourceValue == null) + return prev; + return callback(sourceValue, abort); + }) : createMemo(() => { + const itemSignal = untrack(() => source.byKey(key)); + if (!itemSignal) + return; + const sourceValue = itemSignal.get(); + if (sourceValue == null) + return; + return callback(sourceValue); + }); + signals.set(key, signal); + }; + function syncKeys(nextKeys) { + if (!keysEqual(keys, nextKeys)) { + const nextSet = new Set(nextKeys); + for (const key of keys) + if (!nextSet.has(key)) + signals.delete(key); + for (const key of nextKeys) + if (!signals.has(key)) + addSignal(key); + keys = nextKeys; + node.flags |= FLAG_RELINK; + } + } + function buildValue() { + syncKeys(Array.from(source.keys())); + const result = []; + for (const key of keys) { + try { + const v = signals.get(key)?.get(); + if (v != null) + result.push(v); + } catch (e) { + if (!(e instanceof UnsetSignalValueError)) + throw e; + } + } + return result; + } + const valuesEqual = (a, b) => { + if (a.length !== b.length) + return false; + for (let i = 0;i < a.length; i++) + if (a[i] !== b[i]) + return false; + return true; + }; + const node = { + fn: buildValue, + value: [], + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: valuesEqual, + error: undefined + }; + function ensureFresh() { + if (node.sources) { + if (node.flags) { + node.value = untrack(buildValue); + if (node.flags & FLAG_RELINK) { + node.flags = FLAG_DIRTY; + refresh(node); + if (node.error) + throw node.error; + } else { + node.flags = FLAG_CLEAN; + } + } + } else if (node.sinks) { + refresh(node); + if (node.error) + throw node.error; + } else { + node.value = untrack(buildValue); + } + } + const initialKeys = Array.from(untrack(() => source.keys())); + for (const key of initialKeys) + addSignal(key); + keys = initialKeys; + const collection = { + [Symbol.toStringTag]: TYPE_COLLECTION, + [Symbol.isConcatSpreadable]: true, + *[Symbol.iterator]() { + if (activeSink) + link(node, activeSink); + ensureFresh(); + for (const key of keys) { + const signal = signals.get(key); + if (signal) + yield signal; + } + }, + get length() { + if (activeSink) + link(node, activeSink); + ensureFresh(); + return keys.length; + }, + keys() { + if (activeSink) + link(node, activeSink); + ensureFresh(); + return keys.values(); + }, + get() { + if (activeSink) + link(node, activeSink); + ensureFresh(); + return node.value; + }, + at(index) { + if (activeSink) + link(node, activeSink); + ensureFresh(); + const key = keys[index]; + return key !== undefined ? signals.get(key) : undefined; + }, + byKey(key) { + if (activeSink) + link(node, activeSink); + ensureFresh(); + return signals.get(key); + }, + keyAt(index) { + if (activeSink) + link(node, activeSink); + ensureFresh(); + return keys[index]; + }, + indexOfKey(key) { + if (activeSink) + link(node, activeSink); + ensureFresh(); + return keys.indexOf(key); + }, + deriveCollection(cb) { + return deriveCollection(collection, cb); + } + }; + return collection; +} +function createCollection(watched, options) { + const value = options?.value ?? []; + if (value.length) + validateSignalValue(TYPE_COLLECTION, value, Array.isArray); + validateCallback(TYPE_COLLECTION, watched, isSyncFunction); + const signals = new Map; + const keys = []; + const itemToKey = new Map; + const [generateKey, contentBased] = getKeyGenerator(options?.keyConfig); + const resolveKey = (item) => itemToKey.get(item) ?? (contentBased ? generateKey(item) : undefined); + const itemFactory = options?.createItem ?? ((item) => createState(item, { + equals: options?.itemEquals ?? DEEP_EQUALITY + })); + function buildValue() { + const result = []; + for (const key of keys) { + try { + const v = signals.get(key)?.get(); + if (v != null) + result.push(v); + } catch (e) { + if (!(e instanceof UnsetSignalValueError)) + throw e; + } + } + return result; + } + const node = { + fn: buildValue, + value, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: SKIP_EQUALITY, + error: undefined + }; + for (const item of value) { + const key = generateKey(item); + signals.set(key, itemFactory(item)); + itemToKey.set(item, key); + keys.push(key); + } + node.value = value; + node.flags = FLAG_DIRTY; + const onChanges = (changes) => { + const { add, change, remove } = changes; + if (!add?.length && !change?.length && !remove?.length) + return; + let structural = false; + batch(() => { + if (add) { + const staged = new Map; + for (const item of add) { + const key = generateKey(item); + if (signals.has(key) || staged.has(key)) + throw new DuplicateKeyError(TYPE_COLLECTION, key, item); + staged.set(key, item); + } + for (const [key, item] of staged) { + signals.set(key, itemFactory(item)); + itemToKey.set(item, key); + if (!keys.includes(key)) + keys.push(key); + structural = true; + } + } + if (change) { + for (const item of change) { + const key = resolveKey(item); + if (!key) + continue; + const signal = signals.get(key); + if (signal && isState(signal)) { + itemToKey.delete(untrack(() => signal.get())); + signal.set(item); + itemToKey.set(item, key); + } + } + } + if (remove) { + for (const item of remove) { + const key = resolveKey(item); + if (!key) + continue; + itemToKey.delete(item); + signals.delete(key); + const index = keys.indexOf(key); + if (index !== -1) + keys.splice(index, 1); + structural = true; + } + } + node.flags = FLAG_DIRTY | (structural ? FLAG_RELINK : 0); + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + }); + }; + const subscribe = makeSubscribe(node, () => watched(onChanges)); + const collection = { + [Symbol.toStringTag]: TYPE_COLLECTION, + [Symbol.isConcatSpreadable]: true, + *[Symbol.iterator]() { + subscribe(); + for (const key of keys) { + const signal = signals.get(key); + if (signal) + yield signal; + } + }, + get length() { + subscribe(); + return keys.length; + }, + keys() { + subscribe(); + return keys.values(); + }, + get() { + subscribe(); + if (node.sources) { + if (node.flags) { + const relink = node.flags & FLAG_RELINK; + node.value = untrack(buildValue); + if (relink) { + node.flags = FLAG_DIRTY; + refresh(node); + if (node.error) + throw node.error; + } else { + node.flags = FLAG_CLEAN; + } + } + } else { + refresh(node); + if (node.error) + throw node.error; + } + return node.value; + }, + at(index) { + subscribe(); + const key = keys[index]; + return key !== undefined ? signals.get(key) : undefined; + }, + byKey(key) { + subscribe(); + return signals.get(key); + }, + keyAt(index) { + subscribe(); + return keys[index]; + }, + indexOfKey(key) { + subscribe(); + return keys.indexOf(key); + }, + deriveCollection(cb) { + return deriveCollection(collection, cb); + } + }; + return collection; +} +function isCollection(value) { + return isSignalOfType(value, TYPE_COLLECTION); +} +// src/nodes/effect.ts +function createEffect(fn) { + validateCallback("Effect", fn); + const node = { + fn, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + cleanup: null + }; + const dispose = () => { + runCleanup(node); + node.fn = undefined; + node.flags = FLAG_CLEAN; + node.sourcesTail = null; + trimSources(node); + }; + if (activeOwner) + registerCleanup(activeOwner, dispose); + runEffect(node); + scheduleEffect(node); + return dispose; +} +function match(signalOrSignals, handlers) { + if (!activeOwner) + throw new RequiredOwnerError("match"); + const isSingle = !Array.isArray(signalOrSignals); + const signals = isSingle ? [signalOrSignals] : signalOrSignals; + const { nil, stale } = handlers; + const ok = isSingle ? (values2) => handlers.ok(values2[0]) : (values2) => handlers.ok(values2); + const err = isSingle && handlers.err ? (errors2) => handlers.err(errors2[0]) : handlers.err ?? console.error; + let errors; + let pending = false; + const values = new Array(signals.length); + for (let i = 0;i < signals.length; i++) { + try { + values[i] = signals[i].get(); + } catch (e) { + if (e instanceof UnsetSignalValueError) { + pending = true; + continue; + } + if (!errors) + errors = []; + errors.push(e instanceof Error ? e : new Error(String(e))); + } + } + let out; + try { + if (pending) + out = nil?.(); + else if (errors) + out = err(errors); + else if (stale && (isSingle ? isTask(signals[0]) && signals[0].isPending() : signals.some((s) => isTask(s) && s.isPending()))) + out = stale(); + else + out = ok(values); + } catch (e) { + out = err([e instanceof Error ? e : new Error(String(e))]); + } + if (typeof out === "function") + return out; + if (out instanceof Promise) { + const owner = activeOwner; + const controller = new AbortController; + registerCleanup(owner, () => controller.abort()); + out.then((cleanup) => { + if (!controller.signal.aborted && typeof cleanup === "function") + registerCleanup(owner, cleanup); + }).catch((e) => { + err([e instanceof Error ? e : new Error(String(e))]); + }); + } +} +// src/nodes/sensor.ts +function createSensor(watched, options) { + validateCallback(TYPE_SENSOR, watched, isSyncFunction); + if (options?.value !== undefined) + validateSignalValue(TYPE_SENSOR, options.value, options?.guard); + const node = { + value: options?.value, + sinks: null, + sinksTail: null, + equals: options?.equals ?? DEFAULT_EQUALITY, + guard: options?.guard, + stop: undefined + }; + return { + [Symbol.toStringTag]: TYPE_SENSOR, + get() { + if (activeSink) { + if (!node.sinks) + node.stop = watched((next) => { + validateSignalValue(TYPE_SENSOR, next, node.guard); + setState(node, next); + }); + link(node, activeSink); + } + validateReadValue(TYPE_SENSOR, node.value); + return node.value; + } + }; +} +function isSensor(value) { + return isSignalOfType(value, TYPE_SENSOR); +} +// src/nodes/store.ts +function diffRecords(prev, next) { + const add = {}; + const change = {}; + const remove = {}; + let changed = false; + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(next); + for (const key of nextKeys) { + if (key in prev) { + if (!DEEP_EQUALITY(prev[key], next[key])) { + change[key] = next[key]; + changed = true; + } + } else { + add[key] = next[key]; + changed = true; + } + } + for (const key of prevKeys) { + if (!(key in next)) { + remove[key] = undefined; + changed = true; + } + } + return { add, change, remove, changed }; +} +function createStore(value, options) { + validateSignalValue(TYPE_STORE, value, isRecord); + const signals = new Map; + const addSignal = (key, val) => { + validateSignalValue(`${TYPE_STORE} for key "${key}"`, val); + if (Array.isArray(val)) + signals.set(key, createList(val)); + else if (isRecord(val)) + signals.set(key, createStore(val)); + else + signals.set(key, createState(val)); + }; + const shapeCategory = (val) => { + if (Array.isArray(val)) + return "list"; + if (isRecord(val)) + return "store"; + return "state"; + }; + const signalCategory = (signal) => { + if (isList(signal)) + return "list"; + if (isStore(signal)) + return "store"; + return "state"; + }; + const buildValue = () => { + const record = {}; + for (const [key, signal] of signals) + record[key] = signal.get(); + return record; + }; + const node = { + fn: buildValue, + value, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: DEEP_EQUALITY, + error: undefined + }; + const applyChanges = (changes) => { + let structural = false; + for (const key in changes.add) { + addSignal(key, changes.add[key]); + structural = true; + } + let hasChange = false; + for (const _key in changes.change) { + hasChange = true; + break; + } + if (hasChange) { + batch(() => { + for (const key in changes.change) { + const val = changes.change[key]; + validateSignalValue(`${TYPE_STORE} for key "${key}"`, val); + const signal = signals.get(key); + if (signal) { + if (shapeCategory(val) !== signalCategory(signal)) { + addSignal(key, val); + structural = true; + } else + signal.set(val); + } + } + }); + } + for (const key in changes.remove) { + signals.delete(key); + structural = true; + } + if (structural) + node.flags |= FLAG_RELINK; + return changes.changed; + }; + const subscribe = makeSubscribe(node, options?.watched); + for (const key of Object.keys(value)) + addSignal(key, value[key]); + const store = { + [Symbol.toStringTag]: TYPE_STORE, + [Symbol.isConcatSpreadable]: false, + *[Symbol.iterator]() { + subscribe(); + for (const [key, signal] of signals) { + yield [key, signal]; + } + }, + keys() { + subscribe(); + return signals.keys(); + }, + byKey(key) { + return signals.get(key); + }, + get() { + subscribe(); + if (node.sources) { + if (node.flags) { + const relink = node.flags & FLAG_RELINK; + node.value = untrack(buildValue); + if (relink) { + node.flags = FLAG_DIRTY; + refresh(node); + if (node.error) + throw node.error; + } else { + node.flags = FLAG_CLEAN; + } + } + } else { + refresh(node); + if (node.error) + throw node.error; + } + return node.value; + }, + set(next) { + const prev = node.flags & FLAG_DIRTY ? untrack(buildValue) : node.value; + const changes = diffRecords(prev, next); + if (applyChanges(changes)) { + node.flags |= FLAG_DIRTY; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + } + }, + update(fn) { + store.set(fn(untrack(() => store.get()))); + }, + add(key, value2) { + if (signals.has(key)) + throw new DuplicateKeyError(TYPE_STORE, key, value2); + addSignal(key, value2); + node.flags |= FLAG_DIRTY | FLAG_RELINK; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + return key; + }, + remove(key) { + const ok = signals.delete(key); + if (ok) { + node.flags |= FLAG_DIRTY | FLAG_RELINK; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + } + } + }; + return new Proxy(store, { + get(target, prop) { + if (prop in target) + return Reflect.get(target, prop); + if (typeof prop !== "symbol") + return target.byKey(prop); + }, + set(_target, prop) { + throw new InvalidStoreMutationError(String(prop), "assign to"); + }, + deleteProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), "delete"); + }, + defineProperty(_target, prop) { + throw new InvalidStoreMutationError(String(prop), "define"); + }, + has(target, prop) { + if (prop in target) + return true; + return target.byKey(String(prop)) !== undefined; + }, + ownKeys(target) { + return Array.from(target.keys()); + }, + getOwnPropertyDescriptor(target, prop) { + if (prop in target) + return Reflect.getOwnPropertyDescriptor(target, prop); + if (typeof prop === "symbol") + return; + const signal = target.byKey(String(prop)); + return signal ? { + enumerable: true, + configurable: true, + writable: true, + value: signal + } : undefined; + } + }); +} +function isStore(value) { + return isSignalOfType(value, TYPE_STORE); +} + +// src/signal.ts +var SIGNAL_TYPES = new Set([ + TYPE_STATE, + TYPE_MEMO, + TYPE_TASK, + TYPE_SENSOR, + TYPE_SLOT, + TYPE_LIST, + TYPE_COLLECTION, + TYPE_STORE +]); +function createComputed(callback, options) { + return isAsyncFunction(callback) ? createTask(callback, options) : createMemo(callback, options); +} +function createSignal(value) { + if (isSignal(value)) + return value; + if (value == null) + throw new InvalidSignalValueError("createSignal", value); + if (isAsyncFunction(value)) + return createTask(value); + if (isFunction(value)) + return createMemo(value); + if (Array.isArray(value) && value.every((item) => item != null)) + return createList(value); + if (isRecord(value)) + return createStore(value); + return createState(value); +} +function createMutableSignal(value) { + if (isMutableSignal(value)) + return value; + if (value == null || isFunction(value) || isSignal(value)) + throw new InvalidSignalValueError("createMutableSignal", value); + if (Array.isArray(value) && value.every((item) => item != null)) + return createList(value); + if (isRecord(value)) + return createStore(value); + return createState(value); +} +function isComputed(value) { + return isMemo(value) || isTask(value); +} +function isSignal(value) { + return value != null && SIGNAL_TYPES.has(value[Symbol.toStringTag]); +} +function isMutableSignal(value) { + return isState(value) || isStore(value) || isList(value); +} + +// src/nodes/slot.ts +var settingSlots = new WeakSet; +function isSignalOrDescriptor(value) { + if (isSignal(value)) + return true; + return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function"; +} +function createSlot(initialSignal, options) { + validateSignalValue(TYPE_SLOT, initialSignal, isSignalOrDescriptor); + let delegated = initialSignal; + const guard = options?.guard; + const node = { + fn: () => delegated.get(), + value: undefined, + flags: FLAG_DIRTY, + sources: null, + sourcesTail: null, + sinks: null, + sinksTail: null, + equals: options?.equals ?? DEFAULT_EQUALITY, + error: undefined + }; + const get = () => { + if (activeSink) + link(node, activeSink); + refresh(node); + if (node.error) + throw node.error; + return node.value; + }; + const set = (next) => { + if (settingSlots.has(node)) + throw new Error("[Slot] Circular delegation detected in set()"); + settingSlots.add(node); + try { + if (isSlot(delegated)) + return void delegated.set(next); + if ("set" in delegated && typeof delegated.set === "function") { + validateSignalValue(TYPE_SLOT, next, guard); + delegated.set(next); + } else { + throw new ReadonlySignalError(TYPE_SLOT); + } + } finally { + settingSlots.delete(node); + } + }; + const replace = (next) => { + validateSignalValue(TYPE_SLOT, next, isSignalOrDescriptor); + delegated = next; + node.flags |= FLAG_DIRTY; + for (let e = node.sinks;e; e = e.nextSink) + propagate(e.sink); + if (batchDepth === 0) + flush(); + }; + return { + [Symbol.toStringTag]: TYPE_SLOT, + configurable: true, + enumerable: true, + get, + set, + replace, + current: () => delegated + }; +} +function isSlot(value) { + return isSignalOfType(value, TYPE_SLOT); +} +export { + valueString, + untrack, + unown, + match, + isTask, + isStore, + isState, + isSlot, + isSignalOfType, + isSignal, + isSensor, + isRecord, + isObjectOfType, + isMutableSignal, + isMemo, + isList, + isFunction, + isEqual, + isComputed, + isCollection, + isAsyncFunction, + createTask, + createStore, + createState, + createSlot, + createSignal, + createSensor, + createScope, + createMutableSignal, + createMemo, + createList, + createEffect, + createComputed, + createCollection, + batch, + UnsetSignalValueError, + SKIP_EQUALITY, + RequiredOwnerError, + ReadonlySignalError, + PromiseValueError, + NullishSignalValueError, + InvalidStoreMutationError, + InvalidSignalValueError, + InvalidCallbackError, + EffectConvergenceError, + DuplicateKeyError, + DEFAULT_EQUALITY, + DEEP_EQUALITY, + CircularDependencyError +}; diff --git a/package.json b/package.json index 4a471c9..d71e83c 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,22 @@ "author": "Esther Brunner", "type": "module", "main": "index.js", - "module": "index.ts", "types": "types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "bun": "./index.ts", + "default": "./index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "index.js", + "index.ts", + "src", + "types", + "SECURITY.md" + ], "devDependencies": { "@biomejs/biome": "^2.5.3", "@types/bun": "^1.3.14", @@ -22,6 +36,11 @@ "peerDependencies": { "typescript": ">=5.8.0" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "description": "Cause & Effect - reactive state management primitives library for TypeScript.", "license": "MIT", "keywords": [ @@ -34,7 +53,7 @@ "access": "public" }, "scripts": { - "build": "bunx tsc --project tsconfig.build.json && bun build index.ts --outdir ./ --minify && bun build index.ts --outfile index.dev.js", + "build": "bunx tsc --project tsconfig.build.json && bun build index.ts --outfile index.js", "bench": "bun run bench/reactivity.bench.ts", "test": "bun test --path-ignore-patterns=**/regression*", "regression": "bun test test/regression-performance.test.ts && bun test test/regression-bundle.test.ts", From a2673a0d1cf57041939d668846f7595d66beff68 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 19:41:36 +0200 Subject: [PATCH 10/11] chore: prepare release 1.4.0 --- CHANGELOG.md | 16 ++++++++-------- index.ts | 2 +- package.json | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7132557..9d2b1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,23 @@ # Changelog -## [Unreleased] +## 1.4.0 ### Changed -- **Package distribution metadata overhauled** (`package.json`, `.npmignore`): The published entry point is now the *unminified* ESM bundle (`index.js`, ~50 KB readable) instead of the previous minified artifact; consumers' bundlers minify anyway, and unminified source improves debugging and bug reports. An explicit `exports` map (`.`: `types` → `bun` → `default`, plus `./package.json`) replaces the bare `main`/`module` pair: the `"bun"` condition lets Bun consumers resolve TypeScript source directly, while `"default"` serves the bundled `index.js` to all other toolchains. The `"module": "index.ts"` field is removed — it pointed at raw TypeScript, which broke webpack and older Rollup configs that transcribe `node_modules`. TypeScript is now an *optional* peer dependency (`peerDependenciesMeta`), so JS-only consumers no longer get an unresolvable-peer warning on install. A `files` allowlist replaces the fragile `.npmignore` negation patterns. The unused `index.dev.js` artifact is removed. **Migration:** this is a **minor** (not patch) version bump. The `exports` map intentionally blocks deep imports into package internals (e.g. `@zeix/cause-effect/src/nodes/list.ts`) — nothing in the repo or docs advertised these, and the barrel re-exports the full public API, but code relying on deep imports must switch to named imports from the package root. If your toolchain read the `module` field to consume TypeScript source, switch to the `"bun"` exports condition or import the bundled `index.js`. +- **Package distribution metadata overhauled** (`package.json`): The published entry point is now the *unminified* ESM bundle (`index.js`) instead of a minified artifact — consumers' bundlers minify anyway, and readable source improves debugging and bug reports. An explicit `exports` map (`.`: `types` → `bun` → `default`) replaces the bare `main`/`module` pair: the `"bun"` condition resolves TypeScript source directly for Bun consumers, while `"default"` serves the bundled `index.js` to all other toolchains. The `"module": "index.ts"` field is removed — it pointed at raw TypeScript, which broke webpack and older Rollup configs. TypeScript is now an *optional* peer dependency (`peerDependenciesMeta`), so JS-only consumers no longer get an unresolvable-peer warning. A `files` allowlist replaces the fragile `.npmignore` negation patterns; the unused `index.dev.js` artifact is removed. **Migration:** This is a **minor** version bump. The `exports` map blocks deep imports into package internals — code relying on these must switch to named imports from the package root. If your toolchain read the `module` field to consume TypeScript source, switch to the `"bun"` exports condition or import the bundled `index.js`. ### Added -- **`EffectConvergenceError`**: New error class (exported from the package root), thrown when queued effects keep re-triggering each other without settling within 1000 flush passes. Typical triggers: an effect that unconditionally writes a signal it reads (`createEffect(() => count.set(count.get() + 1))`), or two effects that write each other's dependencies. The error surfaces synchronously from the `set()`/`update()`/`batch()`/`createEffect()` call that triggered the runaway; other queued effects still run before it is thrown. +- **`EffectConvergenceError`**: New error class (exported from the package root), thrown when queued effects keep re-triggering each other without settling within 1000 flush passes. Typical triggers: an effect that unconditionally writes a signal it reads (`createEffect(() => count.set(count.get() + 1))`), or two effects that write each other's dependencies. The error surfaces synchronously from the call that triggered the runaway; other queued effects still run before it is thrown. - **`InvalidStoreMutationError`**: New error class (exported from the package root), a `TypeError` subclass thrown when a `Store` property is assigned, deleted, or defined directly via the proxy (`store.name = 'Bob'`, `delete store.name`, `Object.defineProperty(store, …)`, `Object.assign(store, …)`). The message names the correct reactive alternative (`store.key.set(value)`, `store.set(next)`, `store.add(key, value)`, or `store.remove(key)`). See [ADR-0017](adr/0017-store-proxy-rejects-direct-writes.md). ### Fixed -- **Direct property assignment on a `Store` proxy silently corrupted the store** (`src/nodes/store.ts`): Previously, the proxy defined no `set`, `deleteProperty`, or `defineProperty` traps — so `store.name = 'Bob'` wrote the raw value onto the proxy target object, shadowing the child `State` signal. From then on, `store.name` returned the raw value while `store.get()` returned the reactive value, and the two diverged silently. `delete store.name` similarly bypassed `remove()` semantics. Now the three traps throw `InvalidStoreMutationError`, leaving `store.get()` and the child signal intact. **Migration:** code that previously assigned through the proxy (and silently corrupted state) must use the reactive API (`store.key.set()`, `store.set()`, `store.add()`, `store.remove()`). This is a **minor** version bump, not a patch — the throw replaces previously-undetected silent corruption. See [ADR-0017](adr/0017-store-proxy-rejects-direct-writes.md). -- **A throwing effect no longer skips sibling effects in the same flush** (`src/graph.ts`): Previously, an exception from one effect's callback propagated out of `flush()` immediately — all effects queued after it were silently skipped (their DOM updates and subscriptions lost until some arbitrary later flush), and the throwing effect was left stuck with `FLAG_RUNNING` set, so its next `refresh()` threw a spurious `CircularDependencyError`. Now `flush()` catches per-effect errors, drains the entire queue, and rethrows after: a single error is rethrown as-is (error identity preserved for existing `catch` code), multiple errors are wrapped in an `AggregateError`. `runEffect()` clears `FLAG_RUNNING` in its `finally`, so a previously-throwing effect re-runs normally on the next update. -- **Effects that write signals they depend on now converge** (`src/graph.ts`, `src/nodes/effect.ts`): Previously, `runEffect()` unconditionally overwrote the node's flags with `FLAG_CLEAN` after running, clobbering the dirty re-mark set by the effect's own write — so even a converging clamp effect (`if (v > 10) s.set(10)`) ended one run stale, having rendered the pre-clamp value while the signal held the clamped one; two subscribers of the same signal could disagree. Now `flush()` drains `queuedEffects` in passes over snapshots (effects re-queued during a pass run in the next pass), `propagate()` preserves `FLAG_RUNNING` on effects, and `runEffect()` preserves `FLAG_DIRTY`/`FLAG_CHECK` from the effect's own writes — a self-writing effect re-runs until the graph settles and its last run always observes the final signal values. Creation-time self-writes converge through the same path via a new internal `scheduleEffect()`, which also removes a re-entrant `runEffect` hazard (a write during an effect's creation run previously re-entered the still-running effect via the nested flush). -- **Mutual effect writes no longer hang the process** (`src/graph.ts`): Previously, two effects writing each other's dependencies re-queued each other forever inside one `flush()`, growing the queue until heap exhaustion — there was no loop guard. Now the flush-pass cap (1000) converts this into a loud `EffectConvergenceError` while sibling effects still run. -- **`List` and `Store` mutation methods leaked dependency edges into the caller's effect** (`src/nodes/list.ts`, `src/nodes/store.ts`, `src/nodes/collection.ts`): `List.set()`, `List.sort()`, `List.splice()`, `List.update()`, `Store.update()`, and `createCollection`'s `onChanges` change branch read child item signals without `untrack()`. Calling any of them inside an effect silently subscribed that effect to every item signal touched — causing over-broad re-runs on unrelated item mutations (persistent leak) or a spurious one-time re-run during setup (transient leak, removed by `trimSources` on the next run). `Store.set()` and `List.replace()` already had the correct `untrack` pattern; these methods now match it. The public read APIs (`get()`, `at()`, `byKey()`, `keys()`, `length`, iterator) remain deliberately tracking per [ADR-0015](adr/0015-composite-lookup-methods-track-structural-changes.md). +- **Direct property assignment on a `Store` proxy silently corrupted the store** (`src/nodes/store.ts`): Previously, the proxy defined no `set`, `deleteProperty`, or `defineProperty` traps — so `store.name = 'Bob'` wrote the raw value onto the proxy target, shadowing the child `State` signal. From then on `store.name` returned the raw value while `store.get()` returned the reactive value, diverging silently. Now the three traps throw `InvalidStoreMutationError`, leaving `store.get()` and the child signal intact. **Migration:** code that assigned through the proxy must use the reactive API (`store.key.set()`, `store.set()`, `store.add()`, `store.remove()`). This is a **minor** version bump — the throw replaces previously-undetected silent corruption. See [ADR-0017](adr/0017-store-proxy-rejects-direct-writes.md). +- **A throwing effect no longer skips sibling effects in the same flush** (`src/graph.ts`): Previously, an exception from one effect propagated out of `flush()` immediately — effects queued after it were silently skipped, and the throwing effect was left with `FLAG_RUNNING` set, causing its next `refresh()` to throw a spurious `CircularDependencyError`. Now `flush()` catches per-effect errors, drains the entire queue, and rethrows: a single error as-is (identity preserved), multiple errors wrapped in `AggregateError`. `runEffect()` clears `FLAG_RUNNING` in its `finally`, so a previously-throwing effect re-runs normally on the next update. +- **Effects that write signals they depend on now converge** (`src/graph.ts`, `src/nodes/effect.ts`): Previously, `runEffect()` overwrote the node's flags with `FLAG_CLEAN` after running, clobbering the dirty re-mark set by the effect's own write — so a converging clamp effect (`if (v > 10) s.set(10)`) ended one run stale, having rendered the pre-clamp value. Now `flush()` drains `queuedEffects` in passes over snapshots (effects re-queued during a pass run in the next pass), `propagate()` preserves `FLAG_RUNNING` on effects, and `runEffect()` preserves `FLAG_DIRTY`/`FLAG_CHECK` from the effect's own writes — a self-writing effect re-runs until the graph settles and its last run observes the final signal values. Creation-time self-writes converge via a new internal `scheduleEffect()`, which also removes a re-entrant `runEffect` hazard. +- **Mutual effect writes no longer hang the process** (`src/graph.ts`): Previously, two effects writing each other's dependencies re-queued each other forever inside one `flush()`, growing the queue until heap exhaustion. Now the flush-pass cap (1000) converts this into an `EffectConvergenceError` while sibling effects still run. +- **`List` and `Store` mutation methods leaked dependency edges into the caller's effect** (`src/nodes/list.ts`, `src/nodes/store.ts`, `src/nodes/collection.ts`): `List.set()`, `List.sort()`, `List.splice()`, `List.update()`, `Store.update()`, and `createCollection`'s `onChanges` change branch read child item signals without `untrack()`. Calling any of them inside an effect silently subscribed that effect to every item signal touched — causing over-broad re-runs on unrelated item mutations (persistent leak) or a spurious one-time re-run during setup (transient leak). These methods now wrap reads in `untrack()`, matching the pattern already used by `Store.set()` and `List.replace()`. The public read APIs (`get()`, `at()`, `byKey()`, `keys()`, `length`, iterator) remain deliberately tracking per [ADR-0015](adr/0015-composite-lookup-methods-track-structural-changes.md). ## 1.3.4 diff --git a/index.ts b/index.ts index 7fc4e00..a445922 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,6 @@ /** * @name Cause & Effect - * @version 1.3.4 + * @version 1.4.0 * @author Esther Brunner */ diff --git a/package.json b/package.json index d71e83c..ea91091 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zeix/cause-effect", - "version": "1.3.4", + "version": "1.4.0", "repository": { "type": "git", "url": "https://github.com/zeixcom/cause-effect" From 48564a34f527653922577153930927a2ec6c23d1 Mon Sep 17 00:00:00 2001 From: Esther Brunner Date: Fri, 10 Jul 2026 19:50:54 +0200 Subject: [PATCH 11/11] Update ci.yml --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9052950..c9ca82d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main, next] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest