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/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..9ad9ae4 100644 --- a/.agents/skills/shared/references/non-obvious-behaviors.md +++ b/.agents/skills/shared/references/non-obvious-behaviors.md @@ -198,20 +198,73 @@ If you must wrap a Promise-returning API, always use the `async` keyword so the routes it to `createTask` (with abort/pending support) rather than `createMemo`. - -**`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 +}) +``` + +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. + + + +**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 ``` -Avoid writing to a signal that the same effect reads. If you need derived state, use a -`createMemo` instead. - +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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c9ca82d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main, next] + pull_request: + branches: [main, next] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bunx tsc --noEmit + + - name: Lint and format + run: bunx biome check . + + - name: Unit tests + run: bun run test + + - name: Bundle size regression + run: bun test test/regression-bundle.test.ts + + - name: Build + run: bun run build + + performance: + 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 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/.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/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..9d2b1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 1.4.0 + +### Changed + +- **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 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, 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 ### Fixed 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/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..912700e 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", @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!index.js", "!index.dev.js", "!**/*.d.ts"] + "includes": ["**", "!index.js", "!**/*.d.ts"] }, "formatter": { "enabled": true, @@ -16,12 +16,14 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "preset": "recommended" } }, "javascript": { "formatter": { - "quoteStyle": "double" + "quoteStyle": "single", + "semicolons": "asNeeded", + "arrowParentheses": "asNeeded" } }, "assist": { 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/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/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 056f815..0000000 --- a/index.dev.js +++ /dev/null @@ -1,1807 +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 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"; - } -} -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 = 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_CLEAN; -} -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; - } -} -function flush() { - if (flushing) - return; - flushing = true; - try { - for (let i = 0;i < queuedEffects.length; i++) { - const effect = queuedEffects[i]; - if (effect.flags & (FLAG_DIRTY | FLAG_CHECK)) - refresh(effect); - } - queuedEffects.length = 0; - } finally { - flushing = false; - } -} -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 ? 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(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 = []; - 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; - 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(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); - 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(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); - }, - 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, - InvalidSignalValueError, - InvalidCallbackError, - DuplicateKeyError, - DEFAULT_EQUALITY, - DEEP_EQUALITY, - CircularDependencyError -}; diff --git a/index.js b/index.js index 4a13a0c..eee51ae 100644 --- a/index.js +++ b/index.js @@ -1 +1,1872 @@ -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}; +// 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.ts b/index.ts index 1810384..a445922 100644 --- a/index.ts +++ b/index.ts @@ -1,15 +1,17 @@ /** * @name Cause & Effect - * @version 1.3.4 + * @version 1.4.0 * @author Esther Brunner */ export { CircularDependencyError, DuplicateKeyError, + EffectConvergenceError, type Guard, InvalidCallbackError, InvalidSignalValueError, + InvalidStoreMutationError, NullishSignalValueError, PromiseValueError, ReadonlySignalError, diff --git a/package.json b/package.json index faa1f55..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" @@ -9,10 +9,24 @@ "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.4.6", + "@biomejs/biome": "^2.5.3", "@types/bun": "^1.3.14", "@zeix/cause-effect-stable": "npm:@zeix/cause-effect", "mitata": "^1.0.34", @@ -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,10 +53,11 @@ "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", - "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 76c783c..d67ae29 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. */ @@ -146,6 +163,26 @@ 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( @@ -182,17 +219,19 @@ function validateCallback( } export { - type Guard, CircularDependencyError, - NullishSignalValueError, - InvalidSignalValueError, - UnsetSignalValueError, + DuplicateKeyError, + EffectConvergenceError, + 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 9b1c4c1..e2a449e 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 === */ @@ -231,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 @@ -401,14 +401,13 @@ 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 // 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) } } @@ -515,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) }) @@ -534,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) }) @@ -560,9 +557,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 { @@ -575,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', ) } @@ -594,19 +587,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() + } } /** @@ -763,52 +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 5b2b9b2..5610c1a 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(() => { @@ -449,8 +444,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) } @@ -576,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 0150d6b..01e9a7a 100644 --- a/src/nodes/effect.ts +++ b/src/nodes/effect.ts @@ -15,6 +15,7 @@ import { runCleanup, runEffect, type Signal, + scheduleEffect, trimSources, } from '../graph' import { isTask } from './task' @@ -31,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). */ @@ -65,9 +68,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 +121,7 @@ function createEffect(fn: EffectCallback): Cleanup { if (activeOwner) registerCleanup(activeOwner, dispose) runEffect(node) + scheduleEffect(node) return dispose } @@ -212,19 +223,21 @@ 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))]) + }) } } 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 808cfdc..29618bf 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) @@ -422,7 +414,10 @@ 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 +436,7 @@ function createList< }, update(fn: (prev: T[]) => T[]) { - list.set(fn(list.get())) + list.set(fn(untrack(() => list.get()))) }, at(index: number) { @@ -472,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)) @@ -484,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) @@ -525,10 +516,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]) @@ -548,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, ), ) @@ -564,18 +554,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) @@ -650,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/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 3df1a2b..548a4ea 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, @@ -40,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( @@ -95,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 @@ -136,6 +134,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, @@ -258,11 +268,7 @@ function createStore( for (const [key, signal] of signals) { yield [key, signal] as [ string, - ( - | State - | Store - | List - ), + State | Store | List, ] } }, @@ -273,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 @@ -315,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)) { @@ -327,12 +331,11 @@ function createStore( }, update(fn: (prev: T) => T) { - store.set(fn(store.get())) + store.set(fn(untrack(() => store.get()))) }, 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) @@ -357,6 +360,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 @@ -365,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..485d44e 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) @@ -159,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, } 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 1084775..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 () => {} @@ -614,6 +600,48 @@ 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(() => { @@ -986,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 a31634f..f915a45 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 @@ -351,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') }) }) }) @@ -1092,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 d5a5ce3..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) @@ -991,6 +987,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. @@ -1048,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 5ceaf06..7bed099 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -5,6 +5,7 @@ import { createScope, createState, createStore, + InvalidStoreMutationError, isList, isState, isStore, @@ -384,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], + ]) }) }) @@ -657,6 +656,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 @@ -686,4 +710,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/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"] } 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; /**