diff --git a/Readme.md b/Readme.md index 4f04c27..810b28b 100644 --- a/Readme.md +++ b/Readme.md @@ -30,27 +30,41 @@ Public application-facing facade. - `computed` - `memo` - `effect` +- `createModel` +- `own` +- `isModel` - `createRuntime` - `map` / `filter` / `merge` - `scan` / `hold` / `subscribeOnce` +- model contract: `docs/models.md` ## Recommended Entry Point For application code, start with `@volynets/reflex`. ```ts -import { signal, computed, effect, createRuntime } from "@volynets/reflex"; +import { + createModel, + createRuntime, + effect, + signal, +} from "@volynets/reflex"; const rt = createRuntime(); const [count, setCount] = signal(0); -const double = computed(() => count() * 2); +const createCounterModel = createModel((ctx) => ({ + count, + inc: ctx.action(() => setCount((value) => value + 1)), +})); + +const counter = createCounterModel(); effect(() => { - console.log(count(), double()); + console.log(counter.count()); }); -setCount(5); +counter.inc(); rt.flush(); ``` diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..9e765f6 --- /dev/null +++ b/docs/models.md @@ -0,0 +1,72 @@ +# Models (Facade Contract) + +This document defines the public contract for `createModel()` in +`@volynets/reflex`. It is intentionally strict: models provide an ownership +boundary and predictable lifecycle semantics, not a "maybe reactive" container. + +## Overview + +`createModel(factory)` returns a factory that produces disposable model +instances. Each instance: + +- groups reactive accessors and mutation actions +- defines a strict lifecycle boundary +- owns resources registered through `ctx.onDispose()` or `own(ctx, value)` + +## Model Shape Rules + +The object returned from the model factory may contain only: + +- readable reactive values (`signal()`, `computed()`, `memo()`, etc.) +- actions created with `ctx.action(...)` +- nested plain objects following the same rules + +`effect()` values are forbidden inside model shapes. If a model needs effects, +create them outside the returned object and wire their disposal through +`ctx.onDispose()` or `own(ctx, value)`. + +## Lifecycle + +- Created when you call the factory returned by `createModel(...)`. +- Disposed via `model[Symbol.dispose]()`; disposal is idempotent. +- After the first disposal the model is permanently dead. +- Dead models are not reusable. Construct a new instance instead. + +## Ownership Contract + +- `own(ctx, value)` registers exactly one disposal callback under the model. +- Sharing the same resource across multiple models will dispose it multiple times. +- Passing an already-disposed resource is allowed but discouraged. +- Disposal order is LIFO (last registered cleanup runs first). +- Nested models can be owned: `own(ctx, createChildModel())` is valid. + +## Action Semantics + +- Actions can be nested. +- Actions participate in the current batch scope. +- If no batch is active, the outermost action opens one; nested actions reuse it. +- Actions run untracked. +- If an action throws, the error is rethrown and tracking/batch state is restored. +- Return values pass through unchanged. +- Actions are synchronous for reactive correctness. Async work runs outside the + action's batch/untracked scope. + +## Post-Dispose Behavior + +- Actions always throw after disposal. +- During disposal, the model is already marked dead; actions invoked from cleanups + throw the same as after disposal. +- Reads from previously returned accessors are outside the model contract: they + may appear to work, but are not guaranteed to be valid or stable. + +## Error Policy for Dispose + +- All cleanups run in LIFO order. +- Cleanup errors are logged and do not prevent remaining cleanups from running. + +## Visibility + +- Anything returned from the model is public API. +- `own(ctx, value)` and `ctx.onDispose(...)` are lifecycle primitives, not public + surface. +- Keep internal details private by not returning them, or document them explicitly. diff --git a/packages/@reflex/runtime/AGENTS.md b/packages/@reflex/runtime/AGENTS.md new file mode 100644 index 0000000..d5e9c77 --- /dev/null +++ b/packages/@reflex/runtime/AGENTS.md @@ -0,0 +1,283 @@ +# AGENTS.md + +## Mission +This package is a sync-first reactive runtime. + +Priority order: +1. correctness +2. topology invariants +3. hot-path predictability +4. p99 stability + +## Core Rules +- Do not touch hot code until the hot path is proven. +- Every perf claim must name the function, code region, and workload. +- Prefer fast path / slow path split over one universal clever path. +- Keep hot paths short, boring, and predictable. +- Avoid shape churn on hot objects. +- Avoid polymorphic property access and unstable call-sites in hot code. +- Do not mix broad refactors with local perf patches. + +## P0: Evidence First +- No optimization without a named hot function. +- No optimization without a workload that reproduces the cost. +- No optimization without profile evidence: flamegraph, CPU profile, or benchmark that isolates the cost center. +- No "it seems branchy" or "this probably allocates a lot" language without proof. +- If code is not in the top profile, it is not P0. + +For every perf hypothesis, tie it to exactly one main mechanism: +- shape churn +- polymorphic property access +- polymorphic call-site +- deopt risk +- branch-heavy loop +- expensive traversal + +## P0: Hot Path Must Be Boring +- No rare logic in the hot loop. +- No debug/dev work in the hot loop. +- No universal smart dispatcher in the hot loop. +- No unnecessary function calls in the hot loop. +- No deep `if / else if / else if / else if` ladders in the hot loop. +- Hot path must have one dominant scenario that covers most executions. +- Everything outside the dominant scenario belongs in a slow path. + +Red flag: +- A function that "handles everything" has probably already lost. + +## P0: Shape Stability Is Mandatory +- Every hot object must be created with its full field set up-front. +- Fields must be initialized in the same order every time. +- Do not lazily add hot fields after creation. +- Do not use `delete` on hot objects. +- Do not turn a data object into a mode/dictionary object. +- Do not make one property access site read the same field across unrelated logical layouts. +- Choose `null` vs `undefined` deliberately and keep it stable. + +Checklist for each hot object: +- Which fields are read most often? +- Do those fields live on one stable shape? +- Are different node roles mixed through the same property access site? + +## P0: Property Access Must Stay Predictable +For every hot access site such as: +- `node.state` +- `node.payload` +- `node.compute` +- `edge.prevIn` +- `edge.nextIn` +- `node.firstOut` + +Answer explicitly: +- How many real shapes arrive here? +- Is the site monomorphic? +- If polymorphic, how many variants? +- Is that an intentional tradeoff or accidental architecture debt? + +Red flag: +- One function reads `node.payload` or `node.compute` across producer/computed/watcher/debug roles as if they were one type. + +## P0: Call-Sites Must Be Stable +- Verify every hot call-site: property load or function call. +- Do not mix plain reads and arbitrary function invocation semantics at one hot site. +- `node.compute()` should not share one call-site across unrelated semantics without a measured reason. +- Do not let one hot call-site serve: + - cheap compute + - expensive compute + - side-effecting compute + - different return profiles +- If semantics differ, prefer separate entry points. + +Red flag: +- "Types are the same" is used to justify mixed runtime behavior. + +## P0: Branches Are Judged By Frequency +For every `if` inside a hot function: +- Is this the common case? +- Is it cheap? +- Is the distribution stable? +- Does it drag in traversal, calls, or rare modes? +- Can it be precomputed earlier? +- Can it become one guard with the rest moved to a slow path? + +Target shape: +- one or two cheap guards +- then linear work + +Avoid: +- a seven-branch state machine inside every traversal iteration + +## P0: Deopt Surface Must Be Deliberate +- No unexpected exceptions in hot code. +- No `try/catch` in hot functions without a hard reason. +- No proxies, reflective tricks, or prototype mutation in critical paths. +- No "sometimes number, sometimes object, sometimes symbol" at one critical site unless the cost is measured and accepted. +- Return profiles of hot functions should stay stable in type and semantics. + +Red flag: +- "Sometimes returns boolean, sometimes node, sometimes 0 sentinel." + +## P0: Mean Is Not Enough +Every perf run should preserve: +- mean +- p75 +- p95 +- p99 +- p99.5 or p99.9 when relevant +- variance or RME + +Victory is not only throughput: +- prefer wins that also improve tail latency +- if mean improves but p99 degrades, treat the change as suspicious +- rare slow branches should be measured with injected rare-case workloads + +Red flag: +- "Average got better" without distribution data. + +## P1: Hot Data vs Cold Data +- Move debug metadata out of the hot node shape. +- Dev-only bookkeeping must not live next to critical traversal fields. +- Rare structural flags should not pollute the fast path. +- If a field is rarely needed, it should not interfere with the common case. +- Hot/cold split must be justified by profile data, not taste. + +Useful question: +- Which five fields are needed in 90% of passes? + +## P1: State Representation Must Help The Fast Path +- Bitmasks are not automatically good. +- Avoid turning state into a miniature VM. +- Prefer a fast-state mask for the common case. +- Rare states must not force cascades of checks in every iteration. +- Group flags by read frequency, not only by conceptual neatness. +- Know which flags are read on every hot iteration. + +Red flag: +- One `state` packs lifecycle, tracking, dispose, scheduling, structural mode, and debug mode with no fast/common split. + +## P1: Universal Helpers Must Pass A Perf Interrogation +For every universal function: +- Why is this one path instead of several specialized ones? +- How many logical roles does it mix? +- How many shapes and execution paths does it serve? +- Is it definitely better than splitting by role or phase? + +Suspicious example: +- one function handles invalidate, recompute, effect delivery, and structural fallback in one body + +## P1: Traversal Must Have A Measured Cost +For every traversal: +- What is the average length? +- What is the worst-case length? +- How often does it happen? +- Is it an invariant or just a consequence of the current representation? +- Can pointer hops be reduced? +- Can checks per hop be reduced? + +Inspect especially: +- backward scan to `depsTail` +- reuse search +- reposition edge +- reorder fallback + +## P1: Reorder Policy Must Be Measured, Not Believed +- Compare `reorder_always`, `find_only`, and `no_reorder` separately. +- Choose policy by graph class, not author taste. +- Measure at least: + - static + - mildly dynamic + - rotate/churn + - pathological reorder-heavy +- If reorder does not pay for hot workloads, keep it out of the fast path. + +## P2: Testing Discipline +Performance tests should include: +- monomorphic benchmark +- polymorphic benchmark +- rare slow-path injection benchmark +- shape churn benchmark +- branch-heavy benchmark +- real-world mixed topology benchmark + +Correctness tests should: +- assert semantic contracts, not a specific algorithm +- assert topology invariants +- avoid blocking fast/slow strategy changes + +## P2: Perf Diary Without Self-Deception +For every change, record: +- what changed +- why it should help +- which mechanism it targets: + - IC + - deopt + - branch reduction + - traversal reduction + - call-site stabilization +- what happened to mean +- what happened to p99 +- what broke +- whether the code complexity is worth keeping + +Stop conditions: +- stop when the win is inside noise +- do not sacrifice architecture for a micro-win without tail improvement +- do not make code merely look low-level; make it more predictable +- do not keep a complex optimization without a documented invariant + +## Hard Questions For Every Hot Section +Shape: +- How many shapes really arrive here? + +Access: +- Is this property access monomorphic? + +Call: +- Is this call-site stable? + +Branches: +- Is this branch frequent, or am I just afraid of a rare case? + +State: +- Why is this checked here instead of earlier? + +Data: +- Is this a hot field or cold junk parked next to hot data? + +Tails: +- What does this do to p99? + +## Forbidden Illusions +- "The types are the same, so the JIT is happy." +- "One universal path is simpler, so it must be faster." +- "Bitmask state is always cheaper." +- "Fewer functions is always better." +- "If mean improved, everything is fine." +- "Rare cases do not matter." +- "Shape instability is fine, the engine is smart." + +## Practical Priority +P0: +- flamegraph / CPU profile +- fast path / slow path split +- shape stability +- monomorphic property access +- stable call-sites +- p99 / p999 + +P1: +- state redesign +- traversal / reorder policy +- hot/cold split +- specialization by role + +P2: +- cleanup abstractions +- prettier API shape +- second-order memory polish + +## Patch Style +- Keep patches small and isolated. +- State the hypothesis before changing hot code. +- State the tradeoff after the change. +- If a perf tweak introduces a non-obvious invariant, document it next to the code. diff --git a/packages/@reflex/runtime/RUNTIME.md b/packages/@reflex/runtime/RUNTIME.md index f125b96..955b039 100644 --- a/packages/@reflex/runtime/RUNTIME.md +++ b/packages/@reflex/runtime/RUNTIME.md @@ -80,15 +80,6 @@ export { disposeNodeEvent, } from "./reactivity"; -export { - subtle, - type RuntimeDebugContextSnapshot, - type RuntimeDebugEvent, - type RuntimeDebugListener, - type RuntimeDebugNodeSnapshot, - type RuntimeDebugOptions, - type RuntimeSubtle, -} from "./subtle"; ``` Two clarifications matter: @@ -508,9 +499,9 @@ What happens here: 4. the host decides when to drain `pending` 5. `runWatcher()` performs cleanup-if-needed and re-executes the effect -## Debug API: `subtle` +## Debug API: `@reflex/runtime/debug` -`subtle` is a small introspection surface exported from the package root. +`subtle` is a small introspection surface exported from `@reflex/runtime/debug`. Useful methods include: diff --git a/packages/@reflex/runtime/package.json b/packages/@reflex/runtime/package.json index 65836ca..8a110c9 100644 --- a/packages/@reflex/runtime/package.json +++ b/packages/@reflex/runtime/package.json @@ -18,8 +18,8 @@ "require": "./dist/cjs/index.cjs" }, "./debug": { - "types": "./dist/globals.d.ts", - "import": "./dist/dev/index.js" + "types": "./dist/esm/debug.d.ts", + "import": "./dist/dev/debug.js" } }, "files": [ diff --git a/packages/@reflex/runtime/rollup.config.ts b/packages/@reflex/runtime/rollup.config.ts index a63dd9e..0835b7c 100644 --- a/packages/@reflex/runtime/rollup.config.ts +++ b/packages/@reflex/runtime/rollup.config.ts @@ -8,6 +8,7 @@ import constEnum from "rollup-plugin-const-enum"; type BuildFormat = "esm" | "cjs"; interface BuildTarget { + input: Record; name: string; outDir: string; format: BuildFormat; @@ -34,9 +35,30 @@ const PURE_FUNCS = [ ] as const; const TARGETS: BuildTarget[] = [ - { name: "esm", outDir: "esm", format: "esm", dev: false }, - { name: "esm-dev", outDir: "dev", format: "esm", dev: true }, - { name: "cjs", outDir: "cjs", format: "cjs", dev: false }, + { + input: { index: "build/esm/index.js" }, + name: "esm", + outDir: "esm", + format: "esm", + dev: false, + }, + { + input: { + index: "build/esm/index.js", + debug: "build/esm/debug.js", + }, + name: "esm-dev", + outDir: "dev", + format: "esm", + dev: true, + }, + { + input: { index: "build/esm/index.js" }, + name: "cjs", + outDir: "cjs", + format: "cjs", + dev: false, + }, ]; function compactPlugins(plugins: Array): Plugin[] { @@ -169,7 +191,7 @@ function createPlugins(target: BuildTarget): Plugin[] { function createConfig(target: BuildTarget): RollupOptions { return { input: { - index: "build/esm/index.js", + ...target.input, }, treeshake: { @@ -184,7 +206,6 @@ function createConfig(target: BuildTarget): RollupOptions { output: { dir: `dist/${target.outDir}`, format: target.format, - inlineDynamicImports: true, entryFileNames: target.format === "cjs" ? "[name].cjs" : "[name].js", exports: target.format === "cjs" ? "named" : undefined, sourcemap: target.dev, diff --git a/packages/@reflex/runtime/rollup.perf.config.ts b/packages/@reflex/runtime/rollup.perf.config.ts index f08a3b8..9afd306 100644 --- a/packages/@reflex/runtime/rollup.perf.config.ts +++ b/packages/@reflex/runtime/rollup.perf.config.ts @@ -28,6 +28,8 @@ const createPerfDomain = (input: string, file: string) => ({ export default [ createPerfDomain("build/esm/index.js", "dist/perf.js"), + createPerfDomain("tests/perf/duplicate-read-single-source.jit.mjs", "dist/duplicate-read-single-source.jit.js"), + createPerfDomain("tests/perf/repeated-read-branching.jit.mjs", "dist/repeated-read-branching.jit.js"), createPerfDomain("tests/perf/runtime-versioned-skip.jit.mjs", "dist/runtime-versioned-skip.jit.js"), createPerfDomain("tests/perf/tracking-cleanup-matrix.jit.mjs", "dist/tracking-cleanup-matrix.jit.js"), createPerfDomain("tests/perf/tracking-connect.jit.mjs", "dist/tracking-connect.jit.js"), diff --git a/packages/@reflex/runtime/src/api/read.ts b/packages/@reflex/runtime/src/api/read.ts index e22d765..2cf6b01 100644 --- a/packages/@reflex/runtime/src/api/read.ts +++ b/packages/@reflex/runtime/src/api/read.ts @@ -10,12 +10,13 @@ import { import { ReactiveNodeState, trackReadActive, + tryTrackReadFastPath, DIRTY_STATE, - shouldRecompute, recompute, propagateOnce, clearDirtyState, isDisposedNode, + shouldRecomputeDirtyConsumer, } from "../reactivity"; import { defaultContext } from "../reactivity/context"; @@ -77,16 +78,15 @@ export function readProducer(node: ReactiveNode): T { return node.payload as T; } - const context = defaultContext; - const activeComputed = context.activeComputed; + const activeComputed = defaultContext.activeComputed; // Register this read as a dependency if there's an active computation - if (activeComputed !== null) { - trackReadActive(node, activeComputed, context); + if (activeComputed !== null && !tryTrackReadFastPath(node, activeComputed)) { + trackReadActive(node, activeComputed); } if (__DEV__) { - devRecordReadProducer(node, node.payload, context); + devRecordReadProducer(node, node.payload, defaultContext); } return node.payload as T; @@ -136,30 +136,20 @@ function stabilizeConsumer(node: ReactiveNode): T { return node.payload as T; } - if (__DEV__) { - devAssertConsumerCanStabilize(state); + if (__DEV__) devAssertConsumerCanStabilize(state); + + if ((state & DIRTY_STATE) === 0) { + return node.payload as T; } - // Only proceed if node is marked dirty (has changes to verify) - if ((state & DIRTY_STATE) !== 0) { - // Determine if re-computation is needed: - // - If Changed flag set: upstream definitely changed, skip verification - // - If Invalid flag set: might be transitive stale flag, verify via dependency walk - const needs = - (state & ReactiveNodeState.Changed) !== 0 || shouldRecompute(node); - - if (needs) { - // Re-execute the compute function and update payload - // If value changed AND node has multiple subscribers, notify siblings - const hasSiblings = node.firstOut !== null; - if (recompute(node) && hasSiblings) { - propagateOnce(node); - } - } else { - // Verification confirmed all dirty flags were stale - // Clear dirty state, node is still valid - clearDirtyState(node); - } + if ( + shouldRecomputeDirtyConsumer(node, state) && + recompute(node) && + node.firstOut !== null + ) { + propagateOnce(node); + } else { + clearDirtyState(node); } return node.payload as T; @@ -187,8 +177,8 @@ export function readConsumerLazy(node: ReactiveNode): T { const context = defaultContext; const activeComputed = context.activeComputed; - if (activeComputed !== null) { - trackReadActive(node, activeComputed, context); + if (activeComputed !== null && !tryTrackReadFastPath(node, activeComputed)) { + trackReadActive(node, activeComputed); } if (__DEV__) { diff --git a/packages/@reflex/runtime/src/api/write.ts b/packages/@reflex/runtime/src/api/write.ts index 5f9bffc..fe101df 100644 --- a/packages/@reflex/runtime/src/api/write.ts +++ b/packages/@reflex/runtime/src/api/write.ts @@ -2,7 +2,7 @@ import type { ProducerComparator } from "./compare"; import type { ReactiveNode } from "../reactivity"; import { compare as defaultComparator } from "./compare"; import { devAssertWriteAlive, devRecordWriteProducer } from "../reactivity/dev"; -import { IMMEDIATE, isDisposedNode, propagate } from "../reactivity"; +import { PROMOTE_CHANGED, isDisposedNode, propagate } from "../reactivity"; import { defaultContext } from "../reactivity/context"; /** @@ -19,7 +19,7 @@ import { defaultContext } from "../reactivity/context"; * - Synchronously notifies ALL subscribers through the "push phase" * - All subscribers are marked with Changed state so they'll recompute when read * - * The propagation is SYNCHRONOUS and IMMEDIATE. All nodes reachable from this + * The propagation is synchronous and immediately promotes direct subscribers. * producer are notified before writeProducer returns. This ensures deterministic * ordering and allows batching of changes at a higher level (scheduler). * @@ -114,10 +114,10 @@ export function writeProducer( context.enterPropagation(); try { - // Push phase: notify all subscribers depth-first, mark them dirty - // IMMEDIATE flag: direct subscribers are promoted from Invalid→Changed + // Push phase: notify all subscribers depth-first, mark them dirty. + // Direct subscribers are promoted from Invalid to Changed. // This tells them "definitely changed, don't verify, recompute" - propagate(firstSubscriberEdge, IMMEDIATE); + propagate(firstSubscriberEdge, PROMOTE_CHANGED); } finally { // Always exit propagation phase, even if propagate throws context.leavePropagation(); diff --git a/packages/@reflex/runtime/src/debug.impl.ts b/packages/@reflex/runtime/src/debug.impl.ts new file mode 100644 index 0000000..296a075 --- /dev/null +++ b/packages/@reflex/runtime/src/debug.impl.ts @@ -0,0 +1,336 @@ +import type { ExecutionContext } from "./reactivity/context"; +import type { ReactiveEdge, ReactiveNode } from "./reactivity/shape"; +import { DIRTY_STATE, ReactiveNodeState } from "./reactivity/shape"; +import type { + RuntimeDebugContextSnapshot, + RuntimeDebugEvent, + RuntimeDebugEventType, + RuntimeDebugFlag, + RuntimeDebugDirtyState, + RuntimeDebugListener, + RuntimeDebugNodeKind, + RuntimeDebugNodeRef, + RuntimeDebugNodeSnapshot, + RuntimeDebugOptions, +} from "./debug.types"; + +const DEFAULT_HISTORY_LIMIT = 250; + +interface RuntimeDebugState { + id: number; + nextEventId: number; + history: RuntimeDebugEvent[]; + historyLimit: number; + listeners: Set; +} + +interface RuntimeDebugEventInput { + consumer?: ReactiveNode; + detail?: Record; + node?: ReactiveNode; + source?: ReactiveNode; + target?: ReactiveNode; +} + +const contextStates = new WeakMap(); +const nodeIds = new WeakMap(); +const nodeLabels = new WeakMap(); +const invalidContextKey = {}; +const invalidNodeIds = new Map(); + +let nextContextId = 1; +let nextNodeId = 1; + +function isObjectKey(value: unknown): value is object { + return ( + (typeof value === "object" || typeof value === "function") && value !== null + ); +} + +function normalizeHistoryLimit( + historyLimit: number | undefined, + fallback: number, +): number { + if (historyLimit === undefined) return fallback; + if (!Number.isFinite(historyLimit)) return fallback; + + return Math.max(0, Math.trunc(historyLimit)); +} + +function getDirtyState(state: number): RuntimeDebugDirtyState { + const dirty = state & DIRTY_STATE; + + if (dirty === 0) return "clean"; + if (dirty === ReactiveNodeState.Invalid) return "invalid"; + if (dirty === ReactiveNodeState.Changed) return "changed"; + return "invalid+changed"; +} + +function getNodeKind(state: number): RuntimeDebugNodeKind { + if ((state & ReactiveNodeState.Watcher) !== 0) return "watcher"; + if ((state & ReactiveNodeState.Consumer) !== 0) return "consumer"; + if ((state & ReactiveNodeState.Producer) !== 0) return "producer"; + return "unknown"; +} + +function getFlags(state: number): RuntimeDebugFlag[] { + const flags: RuntimeDebugFlag[] = []; + + if ((state & ReactiveNodeState.Producer) !== 0) flags.push("producer"); + if ((state & ReactiveNodeState.Consumer) !== 0) flags.push("consumer"); + if ((state & ReactiveNodeState.Watcher) !== 0) flags.push("watcher"); + if ((state & ReactiveNodeState.Invalid) !== 0) flags.push("invalid"); + if ((state & ReactiveNodeState.Changed) !== 0) flags.push("changed"); + if ((state & ReactiveNodeState.Visited) !== 0) flags.push("visited"); + if ((state & ReactiveNodeState.Disposed) !== 0) flags.push("disposed"); + if ((state & ReactiveNodeState.Computing) !== 0) flags.push("computing"); + if ((state & ReactiveNodeState.Scheduled) !== 0) flags.push("scheduled"); + if ((state & ReactiveNodeState.Tracking) !== 0) flags.push("tracking"); + + return flags; +} + +function normalizeContextKey(context: ExecutionContext): object { + return isObjectKey(context) ? context : invalidContextKey; +} + +function ensureContextState(context: ExecutionContext): RuntimeDebugState { + const key = normalizeContextKey(context); + const existing = contextStates.get(key); + + if (existing) return existing; + + const state: RuntimeDebugState = { + id: nextContextId++, + nextEventId: 1, + history: [], + historyLimit: DEFAULT_HISTORY_LIMIT, + listeners: new Set(), + }; + + contextStates.set(key, state); + return state; +} + +function ensureNodeId(node: ReactiveNode): number { + if (!isObjectKey(node)) { + const existing = invalidNodeIds.get(node); + if (existing !== undefined) return existing; + + const id = nextNodeId++; + invalidNodeIds.set(node, id); + return id; + } + + const existing = nodeIds.get(node); + + if (existing !== undefined) return existing; + + const id = nextNodeId++; + nodeIds.set(node, id); + return id; +} + +function createNodeRef(node: ReactiveNode): RuntimeDebugNodeRef { + if (!isObjectKey(node)) { + return { + id: ensureNodeId(node), + kind: "unknown", + dirty: "clean", + flags: [], + state: 0, + }; + } + + const label = nodeLabels.get(node); + const ref: RuntimeDebugNodeRef = { + id: ensureNodeId(node), + kind: getNodeKind(node.state), + dirty: getDirtyState(node.state), + flags: getFlags(node.state), + state: node.state, + }; + + if (label !== undefined) { + ref.label = label; + } + + return ref; +} + +function collectAdjacentNodes( + edge: ReactiveEdge | null, + selectNode: (edge: ReactiveEdge) => ReactiveNode, + next: (edge: ReactiveEdge) => ReactiveEdge | null, +): RuntimeDebugNodeRef[] { + const nodes: RuntimeDebugNodeRef[] = []; + + for (let cursor = edge; cursor !== null; cursor = next(cursor)) { + nodes.push(createNodeRef(selectNode(cursor))); + } + + return nodes; +} + +function emitToListeners( + listeners: Set, + event: RuntimeDebugEvent, +): void { + for (const listener of [...listeners]) { + listener(event); + } +} + +function pushHistory(state: RuntimeDebugState, event: RuntimeDebugEvent): void { + if (state.historyLimit === 0) return; + + state.history.push(event); + + const overflow = state.history.length - state.historyLimit; + if (overflow > 0) { + state.history.splice(0, overflow); + } +} + +export function labelDebugNode( + node: T, + label: string | null | undefined, +): T { + if (label && label.length > 0) { + nodeLabels.set(node, label); + } else { + nodeLabels.delete(node); + } + + return node; +} + +export function configureDebugContext( + context: ExecutionContext, + options: RuntimeDebugOptions = {}, +): RuntimeDebugContextSnapshot { + const state = ensureContextState(context); + state.historyLimit = normalizeHistoryLimit( + options.historyLimit, + state.historyLimit, + ); + + const overflow = state.history.length - state.historyLimit; + if (overflow > 0) { + state.history.splice(0, overflow); + } + + return snapshotDebugContext(context); +} + +export function observeDebugContext( + context: ExecutionContext, + listener: RuntimeDebugListener, +): () => void { + const state = ensureContextState(context); + state.listeners.add(listener); + + return () => { + state.listeners.delete(listener); + }; +} + +export function readDebugHistory( + context: ExecutionContext, +): RuntimeDebugEvent[] { + return ensureContextState(context).history.slice(); +} + +export function clearDebugHistory(context: ExecutionContext): void { + ensureContextState(context).history.length = 0; +} + +export function snapshotDebugContext( + context: ExecutionContext, +): RuntimeDebugContextSnapshot { + const state = ensureContextState(context); + const snapshot: RuntimeDebugContextSnapshot = { + id: state.id, + propagationDepth: context.propagationDepth, + historyLimit: state.historyLimit, + historySize: state.history.length, + observerCount: state.listeners.size, + }; + + if (context.activeComputed !== null) { + snapshot.activeComputed = createNodeRef(context.activeComputed); + } + + return snapshot; +} + +export function snapshotDebugNode( + node: ReactiveNode, +): RuntimeDebugNodeSnapshot { + const sources = collectAdjacentNodes( + node.firstIn, + (edge) => edge.from, + (edge) => edge.nextIn, + ); + const subscribers = collectAdjacentNodes( + node.firstOut, + (edge) => edge.to, + (edge) => edge.nextOut, + ); + + return { + ...createNodeRef(node), + payload: node.payload, + hasCompute: node.compute !== null, + inDegree: sources.length, + outDegree: subscribers.length, + sources, + subscribers, + }; +} + +export function collectDebugNodeRefs( + edge: ReactiveEdge | null, + selectNode: (edge: ReactiveEdge) => ReactiveNode, + next: (edge: ReactiveEdge) => ReactiveEdge | null, +): RuntimeDebugNodeRef[] { + return collectAdjacentNodes(edge, selectNode, next); +} + +export function recordDebugEvent( + context: ExecutionContext, + type: RuntimeDebugEventType, + input: RuntimeDebugEventInput = {}, +): RuntimeDebugEvent { + const state = ensureContextState(context); + const event: RuntimeDebugEvent = { + id: state.nextEventId++, + contextId: state.id, + timestamp: Date.now(), + type, + }; + + if (input.node !== undefined) { + event.node = createNodeRef(input.node); + } + + if (input.source !== undefined) { + event.source = createNodeRef(input.source); + } + + if (input.target !== undefined) { + event.target = createNodeRef(input.target); + } + + if (input.consumer !== undefined) { + event.consumer = createNodeRef(input.consumer); + } + + if (input.detail !== undefined) { + event.detail = input.detail; + } + + pushHistory(state, event); + emitToListeners(state.listeners, event); + return event; +} diff --git a/packages/@reflex/runtime/src/debug.install.ts b/packages/@reflex/runtime/src/debug.install.ts new file mode 100644 index 0000000..ffa9656 --- /dev/null +++ b/packages/@reflex/runtime/src/debug.install.ts @@ -0,0 +1,26 @@ +import { + clearDebugHistory, + collectDebugNodeRefs, + configureDebugContext, + labelDebugNode, + observeDebugContext, + readDebugHistory, + recordDebugEvent, + snapshotDebugContext, + snapshotDebugNode, +} from "./debug.impl"; +import { installRuntimeDebug } from "./debug.runtime"; + +installRuntimeDebug({ + clearDebugHistory, + collectDebugNodeRefs, + configureDebugContext, + labelDebugNode, + observeDebugContext, + readDebugHistory, + recordDebugEvent, + snapshotDebugContext, + snapshotDebugNode, +}); + +export const runtimeDebugInstalled = true; diff --git a/packages/@reflex/runtime/src/debug.runtime.ts b/packages/@reflex/runtime/src/debug.runtime.ts new file mode 100644 index 0000000..9f4bdf3 --- /dev/null +++ b/packages/@reflex/runtime/src/debug.runtime.ts @@ -0,0 +1,155 @@ +import type { ExecutionContext } from "./reactivity/context"; +import type { ReactiveEdge, ReactiveNode } from "./reactivity/shape"; +import type { + RuntimeDebugContextSnapshot, + RuntimeDebugEvent, + RuntimeDebugEventType, + RuntimeDebugListener, + RuntimeDebugNodeRef, + RuntimeDebugNodeSnapshot, + RuntimeDebugOptions, +} from "./debug.types"; + +type RecordDebugEventInput = { + consumer?: ReactiveNode; + detail?: Record; + node?: ReactiveNode; + source?: ReactiveNode; + target?: ReactiveNode; +}; + +interface RuntimeDebugImplementation { + clearDebugHistory(context: ExecutionContext): void; + collectDebugNodeRefs( + edge: ReactiveEdge | null, + selectNode: (edge: ReactiveEdge) => ReactiveNode, + next: (edge: ReactiveEdge) => ReactiveEdge | null, + ): RuntimeDebugNodeRef[]; + configureDebugContext( + context: ExecutionContext, + options?: RuntimeDebugOptions, + ): RuntimeDebugContextSnapshot | undefined; + labelDebugNode( + node: T, + label: string | null | undefined, + ): T; + observeDebugContext( + context: ExecutionContext, + listener: RuntimeDebugListener, + ): () => void; + readDebugHistory(context: ExecutionContext): RuntimeDebugEvent[]; + recordDebugEvent( + context: ExecutionContext, + type: RuntimeDebugEventType, + input?: RecordDebugEventInput, + ): RuntimeDebugEvent | undefined; + snapshotDebugContext( + context: ExecutionContext, + ): RuntimeDebugContextSnapshot | undefined; + snapshotDebugNode( + node: ReactiveNode, + ): RuntimeDebugNodeSnapshot | undefined; +} + +const noopUnsubscribe = () => {}; + +const runtimeDebug: RuntimeDebugImplementation = { + clearDebugHistory() {}, + collectDebugNodeRefs() { + return []; + }, + configureDebugContext() { + return undefined; + }, + labelDebugNode(node) { + return node; + }, + observeDebugContext() { + return noopUnsubscribe; + }, + readDebugHistory() { + return []; + }, + recordDebugEvent() { + return undefined; + }, + snapshotDebugContext() { + return undefined; + }, + snapshotDebugNode() { + return undefined; + }, +}; + +export function installRuntimeDebug( + implementation: RuntimeDebugImplementation, +): void { + runtimeDebug.clearDebugHistory = implementation.clearDebugHistory; + runtimeDebug.collectDebugNodeRefs = implementation.collectDebugNodeRefs; + runtimeDebug.configureDebugContext = implementation.configureDebugContext; + runtimeDebug.labelDebugNode = implementation.labelDebugNode; + runtimeDebug.observeDebugContext = implementation.observeDebugContext; + runtimeDebug.readDebugHistory = implementation.readDebugHistory; + runtimeDebug.recordDebugEvent = implementation.recordDebugEvent; + runtimeDebug.snapshotDebugContext = implementation.snapshotDebugContext; + runtimeDebug.snapshotDebugNode = implementation.snapshotDebugNode; +} + +export function clearDebugHistory(context: ExecutionContext): void { + runtimeDebug.clearDebugHistory(context); +} + +export function collectDebugNodeRefs( + edge: ReactiveEdge | null, + selectNode: (edge: ReactiveEdge) => ReactiveNode, + next: (edge: ReactiveEdge) => ReactiveEdge | null, +): RuntimeDebugNodeRef[] { + return runtimeDebug.collectDebugNodeRefs(edge, selectNode, next); +} + +export function configureDebugContext( + context: ExecutionContext, + options: RuntimeDebugOptions = {}, +): RuntimeDebugContextSnapshot | undefined { + return runtimeDebug.configureDebugContext(context, options); +} + +export function labelDebugNode( + node: T, + label: string | null | undefined, +): T { + return runtimeDebug.labelDebugNode(node, label); +} + +export function observeDebugContext( + context: ExecutionContext, + listener: RuntimeDebugListener, +): () => void { + return runtimeDebug.observeDebugContext(context, listener); +} + +export function readDebugHistory( + context: ExecutionContext, +): RuntimeDebugEvent[] { + return runtimeDebug.readDebugHistory(context); +} + +export function recordDebugEvent( + context: ExecutionContext, + type: RuntimeDebugEventType, + input: RecordDebugEventInput = {}, +): RuntimeDebugEvent | undefined { + return runtimeDebug.recordDebugEvent(context, type, input); +} + +export function snapshotDebugContext( + context: ExecutionContext, +): RuntimeDebugContextSnapshot | undefined { + return runtimeDebug.snapshotDebugContext(context); +} + +export function snapshotDebugNode( + node: ReactiveNode, +): RuntimeDebugNodeSnapshot | undefined { + return runtimeDebug.snapshotDebugNode(node); +} diff --git a/packages/@reflex/runtime/src/debug.ts b/packages/@reflex/runtime/src/debug.ts index 95a18b6..0b4cc79 100644 --- a/packages/@reflex/runtime/src/debug.ts +++ b/packages/@reflex/runtime/src/debug.ts @@ -1,384 +1,19 @@ -import type { ExecutionContext } from "./reactivity/context"; -import type { ReactiveEdge, ReactiveNode } from "./reactivity/shape"; -import { DIRTY_STATE, ReactiveNodeState } from "./reactivity/shape"; - -const DEFAULT_HISTORY_LIMIT = 250; - -export type RuntimeDebugEventType = - | "cleanup:stale-sources" - | "compute:error" - | "compute:finish" - | "compute:start" - | "context:enter-propagation" - | "context:hooks" - | "context:optimizations" - | "context:leave-propagation" - | "context:settled" - | "propagate" - | "read:consumer" - | "read:producer" - | "recompute" - | "track:read" - | "watcher:cleanup" - | "watcher:dispose" - | "watcher:invalidated" - | "watcher:run:finish" - | "watcher:run:skip" - | "watcher:run:start" - | "write:producer"; - -export type RuntimeDebugFlag = - | "changed" - | "computing" - | "consumer" - | "disposed" - | "invalid" - | "producer" - | "scheduled" - | "tracking" - | "visited" - | "watcher"; - -export type RuntimeDebugNodeKind = - | "consumer" - | "producer" - | "unknown" - | "watcher"; - -export type RuntimeDebugDirtyState = - | "changed" - | "clean" - | "invalid" - | "invalid+changed"; - -export interface RuntimeDebugOptions { - historyLimit?: number; -} - -export interface RuntimeDebugNodeRef { - id: number; - kind: RuntimeDebugNodeKind; - dirty: RuntimeDebugDirtyState; - flags: RuntimeDebugFlag[]; - label?: string; - state: number; -} - -export interface RuntimeDebugNodeSnapshot extends RuntimeDebugNodeRef { - payload: unknown; - hasCompute: boolean; - inDegree: number; - outDegree: number; - sources: RuntimeDebugNodeRef[]; - subscribers: RuntimeDebugNodeRef[]; -} - -export interface RuntimeDebugContextSnapshot { - id: number; - propagationDepth: number; - historySize: number; - historyLimit: number; - observerCount: number; - activeComputed?: RuntimeDebugNodeRef; -} - -export interface RuntimeDebugEvent { - id: number; - contextId: number; - timestamp: number; - type: RuntimeDebugEventType; - consumer?: RuntimeDebugNodeRef; - detail?: Record; - node?: RuntimeDebugNodeRef; - source?: RuntimeDebugNodeRef; - target?: RuntimeDebugNodeRef; -} - -export type RuntimeDebugListener = (event: RuntimeDebugEvent) => void; - -interface RuntimeDebugState { - id: number; - nextEventId: number; - history: RuntimeDebugEvent[]; - historyLimit: number; - listeners: Set; -} - -interface RuntimeDebugEventInput { - consumer?: ReactiveNode; - detail?: Record; - node?: ReactiveNode; - source?: ReactiveNode; - target?: ReactiveNode; -} - -const contextStates = new WeakMap(); -const nodeIds = new WeakMap(); -const nodeLabels = new WeakMap(); - -let nextContextId = 1; -let nextNodeId = 1; - -function normalizeHistoryLimit( - historyLimit: number | undefined, - fallback: number, -): number { - if (historyLimit === undefined) return fallback; - if (!Number.isFinite(historyLimit)) return fallback; - - return Math.max(0, Math.trunc(historyLimit)); -} - -function getDirtyState(state: number): RuntimeDebugDirtyState { - const dirty = state & DIRTY_STATE; - - if (dirty === 0) return "clean"; - if (dirty === ReactiveNodeState.Invalid) return "invalid"; - if (dirty === ReactiveNodeState.Changed) return "changed"; - return "invalid+changed"; -} - -function getNodeKind(state: number): RuntimeDebugNodeKind { - if ((state & ReactiveNodeState.Watcher) !== 0) return "watcher"; - if ((state & ReactiveNodeState.Consumer) !== 0) return "consumer"; - if ((state & ReactiveNodeState.Producer) !== 0) return "producer"; - return "unknown"; -} - -function getFlags(state: number): RuntimeDebugFlag[] { - const flags: RuntimeDebugFlag[] = []; - - if ((state & ReactiveNodeState.Producer) !== 0) flags.push("producer"); - if ((state & ReactiveNodeState.Consumer) !== 0) flags.push("consumer"); - if ((state & ReactiveNodeState.Watcher) !== 0) flags.push("watcher"); - if ((state & ReactiveNodeState.Invalid) !== 0) flags.push("invalid"); - if ((state & ReactiveNodeState.Changed) !== 0) flags.push("changed"); - if ((state & ReactiveNodeState.Visited) !== 0) flags.push("visited"); - if ((state & ReactiveNodeState.Disposed) !== 0) flags.push("disposed"); - if ((state & ReactiveNodeState.Computing) !== 0) flags.push("computing"); - if ((state & ReactiveNodeState.Scheduled) !== 0) flags.push("scheduled"); - if ((state & ReactiveNodeState.Tracking) !== 0) flags.push("tracking"); - - return flags; -} - -function ensureContextState(context: ExecutionContext): RuntimeDebugState { - const existing = contextStates.get(context); - - if (existing) return existing; - - const state: RuntimeDebugState = { - id: nextContextId++, - nextEventId: 1, - history: [], - historyLimit: DEFAULT_HISTORY_LIMIT, - listeners: new Set(), - }; - - contextStates.set(context, state); - return state; -} - -function ensureNodeId(node: ReactiveNode): number { - const existing = nodeIds.get(node); - - if (existing !== undefined) return existing; - - const id = nextNodeId++; - nodeIds.set(node, id); - return id; -} - -function createNodeRef(node: ReactiveNode): RuntimeDebugNodeRef { - const label = nodeLabels.get(node); - const ref: RuntimeDebugNodeRef = { - id: ensureNodeId(node), - kind: getNodeKind(node.state), - dirty: getDirtyState(node.state), - flags: getFlags(node.state), - state: node.state, - }; - - if (label !== undefined) { - ref.label = label; - } - - return ref; -} - -function collectAdjacentNodes( - edge: ReactiveEdge | null, - selectNode: (edge: ReactiveEdge) => ReactiveNode, - next: (edge: ReactiveEdge) => ReactiveEdge | null, -): RuntimeDebugNodeRef[] { - const nodes: RuntimeDebugNodeRef[] = []; - - for (let cursor = edge; cursor !== null; cursor = next(cursor)) { - nodes.push(createNodeRef(selectNode(cursor))); - } - - return nodes; -} - -function emitToListeners( - listeners: Set, - event: RuntimeDebugEvent, -): void { - for (const listener of [...listeners]) { - listener(event); - } -} - -function pushHistory(state: RuntimeDebugState, event: RuntimeDebugEvent): void { - if (state.historyLimit === 0) return; - - state.history.push(event); - - const overflow = state.history.length - state.historyLimit; - if (overflow > 0) { - state.history.splice(0, overflow); - } -} - -export function labelDebugNode( - node: T, - label: string | null | undefined, -): T { - if (label && label.length > 0) { - nodeLabels.set(node, label); - } else { - nodeLabels.delete(node); - } - - return node; -} - -export function configureDebugContext( - context: ExecutionContext, - options: RuntimeDebugOptions = {}, -): RuntimeDebugContextSnapshot { - const state = ensureContextState(context); - state.historyLimit = normalizeHistoryLimit( - options.historyLimit, - state.historyLimit, - ); - - const overflow = state.history.length - state.historyLimit; - if (overflow > 0) { - state.history.splice(0, overflow); - } - - return snapshotDebugContext(context); -} - -export function observeDebugContext( - context: ExecutionContext, - listener: RuntimeDebugListener, -): () => void { - const state = ensureContextState(context); - state.listeners.add(listener); - - return () => { - state.listeners.delete(listener); - }; -} - -export function readDebugHistory( - context: ExecutionContext, -): RuntimeDebugEvent[] { - return ensureContextState(context).history.slice(); -} - -export function clearDebugHistory(context: ExecutionContext): void { - ensureContextState(context).history.length = 0; -} - -export function snapshotDebugContext( - context: ExecutionContext, -): RuntimeDebugContextSnapshot { - const state = ensureContextState(context); - const snapshot: RuntimeDebugContextSnapshot = { - id: state.id, - propagationDepth: context.propagationDepth, - historyLimit: state.historyLimit, - historySize: state.history.length, - observerCount: state.listeners.size, - }; - - if (context.activeComputed !== null) { - snapshot.activeComputed = createNodeRef(context.activeComputed); - } - - return snapshot; -} - -export function snapshotDebugNode( - node: ReactiveNode, -): RuntimeDebugNodeSnapshot { - const sources = collectAdjacentNodes( - node.firstIn, - (edge) => edge.from, - (edge) => edge.nextIn, - ); - const subscribers = collectAdjacentNodes( - node.firstOut, - (edge) => edge.to, - (edge) => edge.nextOut, - ); - - return { - ...createNodeRef(node), - payload: node.payload, - hasCompute: node.compute !== null, - inDegree: sources.length, - outDegree: subscribers.length, - sources, - subscribers, - }; -} - -export function collectDebugNodeRefs( - edge: ReactiveEdge | null, - selectNode: (edge: ReactiveEdge) => ReactiveNode, - next: (edge: ReactiveEdge) => ReactiveEdge | null, -): RuntimeDebugNodeRef[] { - return collectAdjacentNodes(edge, selectNode, next); -} - -export function recordDebugEvent( - context: ExecutionContext, - type: RuntimeDebugEventType, - input: RuntimeDebugEventInput = {}, -): RuntimeDebugEvent { - const state = ensureContextState(context); - const event: RuntimeDebugEvent = { - id: state.nextEventId++, - contextId: state.id, - timestamp: Date.now(), - type, - }; - - if (input.node !== undefined) { - event.node = createNodeRef(input.node); - } - - if (input.source !== undefined) { - event.source = createNodeRef(input.source); - } - - if (input.target !== undefined) { - event.target = createNodeRef(input.target); - } - - if (input.consumer !== undefined) { - event.consumer = createNodeRef(input.consumer); - } - - if (input.detail !== undefined) { - event.detail = input.detail; - } - - pushHistory(state, event); - emitToListeners(state.listeners, event); - return event; -} +import "./debug_flag"; +import { runtimeDebugInstalled } from "./debug.install"; + +void runtimeDebugInstalled; + +export * from "./index"; +export { subtle, type RuntimeSubtle } from "./subtle"; +export type { + RuntimeDebugContextSnapshot, + RuntimeDebugEvent, + RuntimeDebugEventType, + RuntimeDebugFlag, + RuntimeDebugDirtyState, + RuntimeDebugListener, + RuntimeDebugNodeKind, + RuntimeDebugNodeRef, + RuntimeDebugNodeSnapshot, + RuntimeDebugOptions, +} from "./debug.types"; diff --git a/packages/@reflex/runtime/src/debug.types.ts b/packages/@reflex/runtime/src/debug.types.ts new file mode 100644 index 0000000..87f938b --- /dev/null +++ b/packages/@reflex/runtime/src/debug.types.ts @@ -0,0 +1,91 @@ +export type RuntimeDebugEventType = + | "cleanup:stale-sources" + | "compute:error" + | "compute:finish" + | "compute:start" + | "context:enter-propagation" + | "context:hooks" + | "context:optimizations" + | "context:leave-propagation" + | "context:settled" + | "propagate" + | "read:consumer" + | "read:producer" + | "recompute" + | "track:read" + | "watcher:cleanup" + | "watcher:dispose" + | "watcher:invalidated" + | "watcher:run:finish" + | "watcher:run:skip" + | "watcher:run:start" + | "write:producer"; + +export type RuntimeDebugFlag = + | "changed" + | "computing" + | "consumer" + | "disposed" + | "invalid" + | "producer" + | "scheduled" + | "tracking" + | "visited" + | "watcher"; + +export type RuntimeDebugNodeKind = + | "consumer" + | "producer" + | "unknown" + | "watcher"; + +export type RuntimeDebugDirtyState = + | "changed" + | "clean" + | "invalid" + | "invalid+changed"; + +export interface RuntimeDebugOptions { + historyLimit?: number; +} + +export interface RuntimeDebugNodeRef { + id: number; + kind: RuntimeDebugNodeKind; + dirty: RuntimeDebugDirtyState; + flags: RuntimeDebugFlag[]; + label?: string; + state: number; +} + +export interface RuntimeDebugNodeSnapshot extends RuntimeDebugNodeRef { + payload: unknown; + hasCompute: boolean; + inDegree: number; + outDegree: number; + sources: RuntimeDebugNodeRef[]; + subscribers: RuntimeDebugNodeRef[]; +} + +export interface RuntimeDebugContextSnapshot { + id: number; + propagationDepth: number; + historySize: number; + historyLimit: number; + observerCount: number; + activeComputed?: RuntimeDebugNodeRef; +} + +export interface RuntimeDebugEvent { + id: number; + contextId: number; + timestamp: number; + type: RuntimeDebugEventType; + consumer?: RuntimeDebugNodeRef; + detail?: Record; + node?: RuntimeDebugNodeRef; + source?: RuntimeDebugNodeRef; + target?: RuntimeDebugNodeRef; +} + +export type RuntimeDebugListener = (event: RuntimeDebugEvent) => void; diff --git a/packages/@reflex/runtime/src/debug_flag.ts b/packages/@reflex/runtime/src/debug_flag.ts new file mode 100644 index 0000000..aecd854 --- /dev/null +++ b/packages/@reflex/runtime/src/debug_flag.ts @@ -0,0 +1,7 @@ +const globalScope = globalThis as typeof globalThis & { + __DEV__?: boolean; +}; + +globalScope.__DEV__ = true; + +export {}; diff --git a/packages/@reflex/runtime/src/index.ts b/packages/@reflex/runtime/src/index.ts index d26ad5a..032f2b4 100644 --- a/packages/@reflex/runtime/src/index.ts +++ b/packages/@reflex/runtime/src/index.ts @@ -20,8 +20,10 @@ export { setDefaultContext, resetDefaultContext, type ExecutionContext, + type ExecutionContextOptions, type EngineHooks, type CleanupRegistrar, + type TrackReadFallback, } from "./reactivity/context"; export { @@ -46,13 +48,3 @@ export { disposeNode, disposeNodeEvent, } from "./reactivity"; - -export { - subtle, - type RuntimeDebugContextSnapshot, - type RuntimeDebugEvent, - type RuntimeDebugListener, - type RuntimeDebugNodeSnapshot, - type RuntimeDebugOptions, - type RuntimeSubtle, -} from "./subtle"; diff --git a/packages/@reflex/runtime/src/reactivity/context.ts b/packages/@reflex/runtime/src/reactivity/context.ts index eaad9f8..b2ca313 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -1,5 +1,6 @@ -import type { ReactiveNode } from "./shape"; -import { recordDebugEvent } from "../debug"; +import type { ReactiveEdge, ReactiveNode } from "./shape"; +import { reuseIncomingEdgeFromSuffixOrCreate } from "./shape/methods/connect"; +import { recordDebugEvent } from "../debug.runtime"; export interface EngineHooks { onEffectInvalidated?(node: ReactiveNode): void; @@ -7,208 +8,113 @@ export interface EngineHooks { } export type CleanupRegistrar = (cleanup: () => void) => void; +export type TrackReadFallback = ( + source: ReactiveNode, + consumer: ReactiveNode, + prev: ReactiveEdge | null, + nextExpected: ReactiveEdge | null, + version: number, +) => ReactiveEdge; + +export interface ExecutionContextOptions { + trackReadFallback?: TrackReadFallback; +} type OnEffectInvalidatedHook = EngineHooks["onEffectInvalidated"]; type OnReactiveSettledHook = EngineHooks["onReactiveSettled"]; -const EMPTY_HOOKS = Object.freeze({}) as Readonly; const IS_DEV = typeof __DEV__ !== "undefined" && __DEV__; -const HOOK_VIEW_OWNER = Symbol("ExecutionContext.hookViewOwner"); -const SET_PUBLIC_EFFECT_INVALIDATED = Symbol( - "ExecutionContext.setPublicEffectInvalidated", -); -const SET_PUBLIC_REACTIVE_SETTLED = Symbol( - "ExecutionContext.setPublicReactiveSettled", -); - -type HookOwner = ExecutionContext & { - [SET_PUBLIC_EFFECT_INVALIDATED](hook: OnEffectInvalidatedHook): void; - [SET_PUBLIC_REACTIVE_SETTLED](hook: OnReactiveSettledHook): void; -}; - -type HookView = EngineHooks & { - [HOOK_VIEW_OWNER]: HookOwner; -}; - -function normalizeOwnHook( - hooks: EngineHooks, - key: T, -): EngineHooks[T] | undefined { - if (!Object.hasOwn(hooks, key)) return undefined; - const hook = hooks[key]; - return typeof hook === "function" ? hook : undefined; -} - -function composeEffectInvalidatedDispatch( - runtimeHook: OnEffectInvalidatedHook, - hook: OnEffectInvalidatedHook, -): OnEffectInvalidatedHook { - if (runtimeHook === undefined) return hook; - if (hook === undefined) return runtimeHook; - - return function (node) { - runtimeHook(node); - hook(node); - }; -} - -function composeSettledDispatch( - runtimeHook: OnReactiveSettledHook, - hook: OnReactiveSettledHook, -): OnReactiveSettledHook { - if (runtimeHook === undefined) return hook; - if (hook === undefined) return runtimeHook; - - return () => { - runtimeHook(); - hook(); - }; -} - -function getEffectInvalidatedHook(this: HookView): OnEffectInvalidatedHook { - return this[HOOK_VIEW_OWNER].onEffectInvalidatedHook; -} - -function setEffectInvalidatedHook( - this: HookView, - hook: OnEffectInvalidatedHook, -): void { - this[HOOK_VIEW_OWNER][SET_PUBLIC_EFFECT_INVALIDATED](hook); -} - -function getReactiveSettledHook(this: HookView): OnReactiveSettledHook { - return this[HOOK_VIEW_OWNER].onReactiveSettledHook; -} - -function setReactiveSettledHook( - this: HookView, - hook: OnReactiveSettledHook, -): void { - this[HOOK_VIEW_OWNER][SET_PUBLIC_REACTIVE_SETTLED](hook); -} - -const HOOK_VIEW_DESCRIPTORS: PropertyDescriptorMap = { - onEffectInvalidated: { - enumerable: true, - get: getEffectInvalidatedHook, - set: setEffectInvalidatedHook, - }, - onReactiveSettled: { - enumerable: true, - get: getReactiveSettledHook, - set: setReactiveSettledHook, - }, -}; - -function createHookView(owner: HookOwner): EngineHooks { - const hooks = Object.create( - Object.prototype, - HOOK_VIEW_DESCRIPTORS, - ) as HookView; - Object.defineProperty(hooks, HOOK_VIEW_OWNER, { - value: owner, - }); - return hooks; -} +export let dispatchEffectInvalidated = undefined as OnEffectInvalidatedHook; +export let trackReadFallback: TrackReadFallback = + reuseIncomingEdgeFromSuffixOrCreate; export class ExecutionContext { activeComputed: ReactiveNode | null = null; + trackingVersion = 0; propagationDepth = 0; cleanupRegistrar: CleanupRegistrar | null = null; - readonly hooks: EngineHooks; - onEffectInvalidatedHook: OnEffectInvalidatedHook = undefined; - onReactiveSettledHook: OnReactiveSettledHook = undefined; - runtimeOnEffectInvalidatedHook: OnEffectInvalidatedHook = undefined; - runtimeOnReactiveSettledHook: OnReactiveSettledHook = undefined; + trackReadFallback: TrackReadFallback = reuseIncomingEdgeFromSuffixOrCreate; + + onEffectInvalidated: OnEffectInvalidatedHook = undefined; + onReactiveSettled: OnReactiveSettledHook = undefined; + + runtimeOnEffectInvalidated: OnEffectInvalidatedHook = undefined; + runtimeOnReactiveSettled: OnReactiveSettledHook = undefined; + effectInvalidatedDispatch: OnEffectInvalidatedHook = undefined; settledDispatch: OnReactiveSettledHook = undefined; - constructor(hooks: EngineHooks = EMPTY_HOOKS) { - this.hooks = createHookView(this as HookOwner); + constructor(hooks: EngineHooks = {}, options: ExecutionContextOptions = {}) { this.setHooks(hooks); + this.setOptions(options); } dispatchWatcherEvent(node: ReactiveNode): void { - const dispatch = this.effectInvalidatedDispatch; - if (!IS_DEV && dispatch === undefined) return; - - if (IS_DEV) { - recordDebugEvent(this, "watcher:invalidated", { node }); - } - - if (dispatch !== undefined) dispatch(node); + if (IS_DEV) recordDebugEvent(this, "watcher:invalidated", { node }); + this.effectInvalidatedDispatch?.(node); } maybeNotifySettled(): void { - const dispatch = this.settledDispatch; - if (!IS_DEV && dispatch === undefined) return; if (this.propagationDepth !== 0 || this.activeComputed !== null) return; - - if (IS_DEV) { - recordDebugEvent(this, "context:settled"); - } - - if (dispatch !== undefined) dispatch(); + if (IS_DEV) recordDebugEvent(this, "context:settled"); + this.settledDispatch?.(); } enterPropagation(): void { ++this.propagationDepth; - - if (IS_DEV) { + if (IS_DEV) recordDebugEvent(this, "context:enter-propagation", { - detail: { - depth: this.propagationDepth, - }, + detail: { depth: this.propagationDepth }, }); - } } leavePropagation(): void { - if (this.propagationDepth > 0) { - --this.propagationDepth; - } - - if (IS_DEV) { + if (this.propagationDepth > 0) --this.propagationDepth; + if (IS_DEV) recordDebugEvent(this, "context:leave-propagation", { - detail: { - depth: this.propagationDepth, - }, + detail: { depth: this.propagationDepth }, }); - } - this.maybeNotifySettled(); } resetState(): void { this.activeComputed = null; + this.trackingVersion = 0; this.propagationDepth = 0; this.cleanupRegistrar = null; } - setHooks(hooks: EngineHooks = EMPTY_HOOKS): void { - this[SET_PUBLIC_EFFECT_INVALIDATED]( - normalizeOwnHook(hooks, "onEffectInvalidated"), - ); - this[SET_PUBLIC_REACTIVE_SETTLED]( - normalizeOwnHook(hooks, "onReactiveSettled"), - ); - this.recordHookSnapshot(); + setOptions(options: ExecutionContextOptions = {}): void { + this.trackReadFallback = trackReadFallback = + typeof options.trackReadFallback === "function" + ? options.trackReadFallback + : reuseIncomingEdgeFromSuffixOrCreate; + } + + setHooks(hooks: EngineHooks = {}): void { + this.onEffectInvalidated = + typeof hooks.onEffectInvalidated === "function" + ? hooks.onEffectInvalidated + : undefined; + this.onReactiveSettled = + typeof hooks.onReactiveSettled === "function" + ? hooks.onReactiveSettled + : undefined; + this.refreshDispatchers(); } setRuntimeHooks( onEffectInvalidated: OnEffectInvalidatedHook = undefined, onReactiveSettled: OnReactiveSettledHook = undefined, ): void { - this.runtimeOnEffectInvalidatedHook = + this.runtimeOnEffectInvalidated = typeof onEffectInvalidated === "function" ? onEffectInvalidated : undefined; - this.runtimeOnReactiveSettledHook = + this.runtimeOnReactiveSettled = typeof onReactiveSettled === "function" ? onReactiveSettled : undefined; - this.refreshEffectInvalidatedDispatch(); - this.refreshSettledDispatch(); - this.recordHookSnapshot(); + this.refreshDispatchers(); } registerWatcherCleanup(cleanup: () => void): void { @@ -216,59 +122,54 @@ export class ExecutionContext { } withCleanupRegistrar(registrar: CleanupRegistrar | null, fn: () => T): T { - const previousRegistrar = this.cleanupRegistrar; + const prev = this.cleanupRegistrar; this.cleanupRegistrar = registrar; - try { return fn(); } finally { - this.cleanupRegistrar = previousRegistrar; + this.cleanupRegistrar = prev; } } - [SET_PUBLIC_EFFECT_INVALIDATED](hook: OnEffectInvalidatedHook): void { - this.onEffectInvalidatedHook = - typeof hook === "function" ? hook : undefined; - this.refreshEffectInvalidatedDispatch(); - } - - [SET_PUBLIC_REACTIVE_SETTLED](hook: OnReactiveSettledHook): void { - this.onReactiveSettledHook = typeof hook === "function" ? hook : undefined; - this.refreshSettledDispatch(); - } - - private refreshEffectInvalidatedDispatch(): void { - this.effectInvalidatedDispatch = composeEffectInvalidatedDispatch( - this.runtimeOnEffectInvalidatedHook, - this.onEffectInvalidatedHook, - ); - } - - private refreshSettledDispatch(): void { - this.settledDispatch = composeSettledDispatch( - this.runtimeOnReactiveSettledHook, - this.onReactiveSettledHook, - ); - } - - private recordHookSnapshot(): void { - if (!IS_DEV) return; - - recordDebugEvent(this, "context:hooks", { - detail: { - hasOnEffectInvalidated: this.effectInvalidatedDispatch !== undefined, - hasOnReactiveSettled: this.settledDispatch !== undefined, - }, - }); + private refreshDispatchers(): void { + const ri = this.runtimeOnEffectInvalidated, + pi = this.onEffectInvalidated; + const rs = this.runtimeOnReactiveSettled, + ps = this.onReactiveSettled; + + this.effectInvalidatedDispatch = dispatchEffectInvalidated = + ri && pi + ? function (node) { + ri(node); + pi(node); + } + : (ri ?? pi); + + this.settledDispatch = + rs && ps + ? function () { + rs(); + ps(); + } + : (rs ?? ps); + + if (IS_DEV) + recordDebugEvent(this, "context:hooks", { + detail: { + hasOnEffectInvalidated: this.effectInvalidatedDispatch !== undefined, + hasOnReactiveSettled: this.settledDispatch !== undefined, + }, + }); } } -export let defaultContext = createExecutionContext(EMPTY_HOOKS); +export let defaultContext = new ExecutionContext(); export function createExecutionContext( - hooks: EngineHooks = EMPTY_HOOKS, + hooks: EngineHooks = {}, + options: ExecutionContextOptions = {}, ): ExecutionContext { - return new ExecutionContext(hooks); + return new ExecutionContext(hooks, options); } export function getDefaultContext(): ExecutionContext { @@ -282,9 +183,8 @@ export function setDefaultContext(context: ExecutionContext): ExecutionContext { } export function resetDefaultContext( - hooks: EngineHooks = EMPTY_HOOKS, + hooks: EngineHooks = {}, + options: ExecutionContextOptions = {}, ): ExecutionContext { - const next = new ExecutionContext(hooks); - defaultContext = next; - return next; + return (defaultContext = new ExecutionContext(hooks, options)); } diff --git a/packages/@reflex/runtime/src/reactivity/dev.ts b/packages/@reflex/runtime/src/reactivity/dev.ts index 86775ea..19982b4 100644 --- a/packages/@reflex/runtime/src/reactivity/dev.ts +++ b/packages/@reflex/runtime/src/reactivity/dev.ts @@ -1,7 +1,7 @@ import { collectDebugNodeRefs, recordDebugEvent, -} from "../debug"; +} from "../debug.runtime"; import type { ExecutionContext } from "./context"; import { type ReactiveEdge, diff --git a/packages/@reflex/runtime/src/reactivity/engine/execute.ts b/packages/@reflex/runtime/src/reactivity/engine/execute.ts index 62d93af..345d663 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/execute.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/execute.ts @@ -1,4 +1,4 @@ -import { recordDebugEvent } from "../../debug"; +import { recordDebugEvent } from "../../debug.runtime"; import type { ReactiveNode } from "../shape"; import { ReactiveNodeState, @@ -8,17 +8,18 @@ import { import { cleanupStaleSources } from "./tracking"; import { defaultContext } from "../context"; -function prepareNodeExecution(node: ReactiveNode): () => void { +function prepareNodeExecution(node: ReactiveNode): ReactiveNode | null { const context = defaultContext; + const nextVersion = (context.trackingVersion + 1) >>> 0; node.depsTail = null; node.state = (node.state & ~ReactiveNodeState.Visited) | ReactiveNodeState.Tracking; markNodeComputing(node); + context.trackingVersion = nextVersion === 0 ? 1 : nextVersion; const prevActive = context.activeComputed; context.activeComputed = node; - let restored = false; if (__DEV__) { recordDebugEvent(context, "compute:start", { @@ -26,13 +27,16 @@ function prepareNodeExecution(node: ReactiveNode): () => void { }); } - return () => { - if (restored) return; - restored = true; - context.activeComputed = prevActive; - node.state &= ~ReactiveNodeState.Tracking; - clearNodeComputing(node); - }; + return prevActive; +} + +function restoreNodeExecution( + node: ReactiveNode, + prevActive: ReactiveNode | null, +): void { + defaultContext.activeComputed = prevActive; + node.state &= ~ReactiveNodeState.Tracking; + clearNodeComputing(node); } export function executeNodeComputation(node: ReactiveNode): unknown { @@ -48,12 +52,12 @@ export function executeNodeComputation(node: ReactiveNode): unknown { } const compute = node.compute!; - const restoreActive = prepareNodeExecution(node); + const prevActive = prepareNodeExecution(node); let result: unknown; try { result = compute(); - restoreActive(); + restoreNodeExecution(node, prevActive); if (node.depsTail !== node.lastIn) { cleanupStaleSources(node); @@ -70,7 +74,7 @@ export function executeNodeComputation(node: ReactiveNode): unknown { return result; } catch (error) { - restoreActive(); + restoreNodeExecution(node, prevActive); if (__DEV__) { recordDebugEvent(defaultContext, "compute:error", { diff --git a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts index f8b506f..03ed73b 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -7,33 +7,79 @@ import { } from "../dev"; import { linkEdge, - reuseIncomingEdgeFromSuffixOrCreate, unlinkDetachedIncomingEdgeSequence, } from "../shape/methods/connect"; -import { defaultContext } from "../context"; +import { defaultContext, trackReadFallback } from "../context"; /** * Cursor-guided incoming-edge walk used during dependency collection. * It first probes the hot cache and expected next edge, then falls back to a * linear scan that reorders the found edge into the reused dependency prefix. */ -export function trackRead( - source: ReactiveNode, -): void { +export function trackRead(source: ReactiveNode): void { const context = defaultContext; const consumer = context.activeComputed; if (!consumer) return; - trackReadActive(source, consumer, context); + if (tryTrackReadFastPath(source, consumer)) return; + trackReadActive(source, consumer); +} + +/** + * Consumer-local cursor fast path that avoids entering the full tracking path + * when the next unique dependency is already obvious from the current cursor. + * + * - Immediate duplicate (`depsTail.from === source`) is structurally inert. + * - Expected next (`depsTail.nextIn === source`, or `firstIn` when no tail yet) + * reuses the existing edge and advances the cursor. + */ +export function tryTrackReadFastPath( + source: ReactiveNode, + consumer: ReactiveNode, +): boolean { + const prevEdge = consumer.depsTail; + const version = defaultContext.trackingVersion; + + const lastOut = source.lastOut; + if (lastOut != null && lastOut.version === version && lastOut.to === consumer) { + + return true; + } + + if (prevEdge != null) { + if (prevEdge.from === source) { + prevEdge.version = version; + return true; + } + + const nextExpected = prevEdge.nextIn; + if (nextExpected != null && nextExpected.from === source) { + nextExpected.version = version; + consumer.depsTail = nextExpected; + return true; + } + + return false; + } + + const firstIn = consumer.firstIn; + if (firstIn != null && firstIn.from === source) { + firstIn.version = version; + consumer.depsTail = firstIn; + return true; + } + + return false; } export function trackReadActive( source: ReactiveNode, consumer: ReactiveNode, - context = defaultContext, ): void { + const version = defaultContext.trackingVersion; const sourceDead = isDisposedNode(source); const consumerDead = isDisposedNode(consumer); + if (sourceDead || consumerDead) { if (__DEV__) { devAssertTrackReadAlive(sourceDead, consumerDead); @@ -43,7 +89,7 @@ export function trackReadActive( } if (__DEV__) { - devRecordTrackRead(context, consumer, source); + devRecordTrackRead(defaultContext, consumer, source); } const prevEdge = consumer.depsTail; @@ -51,52 +97,59 @@ export function trackReadActive( const firstIn = consumer.firstIn; if (firstIn === null) { - consumer.depsTail = linkEdge(source, consumer, null); + consumer.depsTail = linkEdge(source, consumer, null, version); return; } if (firstIn.from === source) { + firstIn.version = version; consumer.depsTail = firstIn; return; } if (firstIn.nextIn === null) { - consumer.depsTail = linkEdge(source, consumer, null); + consumer.depsTail = linkEdge(source, consumer, null, version); return; } - consumer.depsTail = reuseIncomingEdgeFromSuffixOrCreate( + consumer.depsTail = trackReadFallback( source, consumer, null, firstIn, + version, ); return; } - if (prevEdge.from === source) return; + if (prevEdge.from === source) { + prevEdge.version = version; + return; + } const nextExpected = prevEdge.nextIn; if (nextExpected === null) { - consumer.depsTail = linkEdge(source, consumer, prevEdge); + consumer.depsTail = linkEdge(source, consumer, prevEdge, version); return; } if (nextExpected.from === source) { + nextExpected.version = version; consumer.depsTail = nextExpected; return; } if (nextExpected.nextIn === null) { - consumer.depsTail = linkEdge(source, consumer, prevEdge); + consumer.depsTail = linkEdge(source, consumer, prevEdge, version); return; } - consumer.depsTail = reuseIncomingEdgeFromSuffixOrCreate( + consumer.depsTail = trackReadFallback( source, consumer, prevEdge, nextExpected, + version, ); } @@ -108,6 +161,7 @@ export function cleanupStaleSources(node: ReactiveNode): void { const tail = node.depsTail; const staleHead = tail === null ? node.firstIn : tail.nextIn; if (staleHead === null) return; + const detachedStaleHead: NonNullable = staleHead; if (tail === null) { node.firstIn = null; @@ -118,8 +172,8 @@ export function cleanupStaleSources(node: ReactiveNode): void { } if (__DEV__) { - devRecordCleanupStaleSources(node, staleHead, defaultContext); + devRecordCleanupStaleSources(node, detachedStaleHead, defaultContext); } - unlinkDetachedIncomingEdgeSequence(staleHead); + unlinkDetachedIncomingEdgeSequence(detachedStaleHead); } diff --git a/packages/@reflex/runtime/src/reactivity/engine/watcher.ts b/packages/@reflex/runtime/src/reactivity/engine/watcher.ts index b15a0c6..43708aa 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/watcher.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/watcher.ts @@ -1,5 +1,5 @@ -import { recordDebugEvent } from "../../debug"; -import { shouldRecompute } from "../walkers/recompute"; +import { recordDebugEvent } from "../../debug.runtime"; +import { shouldRecomputeDirtyWatcher } from "../walkers/recompute"; import type { ReactiveNode } from "../shape"; import { clearNodeVisited, @@ -63,30 +63,30 @@ export function runWatcher(node: ReactiveNode): void { const state = node.state; if ((state & ReactiveNodeState.Disposed) !== 0) { - recordWatcherSkip(node, "disposed"); + if (__DEV__) recordWatcherSkip(node, "disposed"); return; } if ((state & DIRTY_STATE) === 0) { - recordWatcherSkip(node, "clean"); + if (__DEV__) recordWatcherSkip(node, "clean"); return; } - if (!shouldRecompute(node)) { + if (!shouldRecomputeDirtyWatcher(node, state)) { clearDirtyState(node); - recordWatcherSkip(node, "stable"); + if (__DEV__) recordWatcherSkip(node, "stable"); return; } const prevCleanup = getWatcherCleanup(node.payload); - recordWatcherStart(node, prevCleanup !== null); + if (__DEV__) recordWatcherStart(node, prevCleanup !== null); node.payload = UNINITIALIZED; clearNodeVisited(node); if (prevCleanup !== null) { prevCleanup(); - recordWatcherCleanup(node); + if (__DEV__) recordWatcherCleanup(node); } if ((node.state & ReactiveNodeState.Disposed) !== 0) { @@ -94,11 +94,10 @@ export function runWatcher(node: ReactiveNode): void { return; } - let hasCleanup = false; const result = executeNodeComputation(node); + const hasCleanup = typeof result === "function"; - if (typeof result === "function") { - if (__DEV__) hasCleanup = true; + if (hasCleanup) { node.payload = result as () => void; } diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveEdge.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveEdge.ts index 05ace32..225d929 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveEdge.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveEdge.ts @@ -6,6 +6,7 @@ import type ReactiveNode from "./ReactiveNode"; * incoming list, so pointer rewrites must keep both views in sync. */ class ReactiveEdge { + version: number; prevOut: ReactiveEdge | null; nextOut: ReactiveEdge | null; from: ReactiveNode; @@ -14,6 +15,7 @@ class ReactiveEdge { nextIn: ReactiveEdge | null; constructor( + version: number, prevOut: ReactiveEdge | null, nextOut: ReactiveEdge | null, from: ReactiveNode, @@ -21,6 +23,7 @@ class ReactiveEdge { prevIn: ReactiveEdge | null, nextIn: ReactiveEdge | null, ) { + this.version = version; this.prevOut = prevOut; this.nextOut = nextOut; this.from = from; diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts index ff51a6a..cffeae2 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts @@ -1,9 +1,17 @@ import type { Reactivable } from "./Reactivable"; import type { ReactiveEdge } from "./ReactiveEdge"; +// TODO: Potentially polymorphic path if fist comes symbol then anouther type const UNINITIALIZED: unique symbol = Symbol.for("UNINITIALIZED"); -export type Primitive = string | number | boolean | bigint | symbol | null | undefined; +export type Primitive = + | string + | number + | boolean + | bigint + | symbol + | null + | undefined; export type Payload = T extends Primitive | Function ? T @@ -25,7 +33,7 @@ class ReactiveNode implements Reactivable { payload: T; constructor(payload: T, compute: ComputeFn, state: number) { - this.state = state; + this.state = state | 0; this.firstOut = null; this.firstIn = null; this.lastOut = null; diff --git a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts index 666f147..6641621 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts @@ -46,10 +46,19 @@ export function linkEdge( from: ReactiveNode, to: ReactiveNode, after: ReactiveEdge | null = to.lastIn, + version = 0, ): ReactiveEdge { const prevOut = from.lastOut; const nextIn = after ? after.nextIn : to.firstIn; - const edge = new ReactiveEdge(prevOut, null, from, to, after, nextIn); + const edge = new ReactiveEdge( + version, + prevOut, + null, + from, + to, + after, + nextIn, + ); if (prevOut) prevOut.nextOut = edge; else from.firstOut = edge; @@ -83,10 +92,13 @@ export function reuseIncomingEdgeFromSuffixOrCreate( to: ReactiveNode, prev: ReactiveEdge | null, nextExpected: ReactiveEdge | null, + version = 0, ): ReactiveEdge { - // Scan the rest of the incoming list for a reusable edge. + // Scan the remaining suffix for a reusable edge. + // `nextExpected` already points at the first still-available edge after the + // reused prefix, so the fallback scan must include it. for ( - let edge = nextExpected ? nextExpected.nextIn : to.firstIn; + let edge = nextExpected ?? to.firstIn; edge !== null; edge = edge.nextIn ) { @@ -98,10 +110,11 @@ export function reuseIncomingEdgeFromSuffixOrCreate( attachInEdge(to, edge, prev); } + edge.version = version; return edge; } - return linkEdge(from, to, prev); + return linkEdge(from, to, prev, version); } export function unlinkDetachedIncomingEdgeSequence( @@ -172,7 +185,7 @@ export function connect( ): ReactiveEdge { const depsTail = child.lastIn; - if (depsTail?.from === parent) { + if (depsTail !== null && depsTail.from === parent) { return depsTail; } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts index 71fc293..4deef2c 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts @@ -1,35 +1,44 @@ import { ReactiveNodeState } from "../shape"; /** - * NON_IMMEDIATE flag: somwhere in the middle subscribers are promoted Invalid. + * Promotion mode for transitive subscribers: mark them as Invalid. * - * This tells them "maybe changed, verify dependencies and then recompute" + * This tells them "maybe changed, verify dependencies and then recompute". */ -export const NON_IMMEDIATE = ReactiveNodeState.Invalid; +export const PROMOTE_INVALID = ReactiveNodeState.Invalid; /** - * IMMEDIATE flag: direct subscribers are promoted from Invalid → Changed. + * Promotion mode for direct subscribers: mark them as Changed. * - * This tells them "definitely changed, don't verify, recompute" + * This tells them "definitely changed, don't verify, recompute". */ -export const IMMEDIATE = ReactiveNodeState.Changed; +export const PROMOTE_CHANGED = ReactiveNodeState.Changed; -export const CAN_ESCAPE_INVALIDATION = +export const ALREADY_DIRTY_MASK = ReactiveNodeState.Invalid | - ReactiveNodeState.Changed | - ReactiveNodeState.Disposed | - ReactiveNodeState.Visited | - ReactiveNodeState.Tracking; + ReactiveNodeState.Changed; + +export const TERMINAL_MASK = ReactiveNodeState.Disposed; +export const TRANSITIONAL_MASK = ReactiveNodeState.Tracking; +export const TRAVERSAL_GUARD_MASK = ReactiveNodeState.Visited; -// Только то, что реально требует slow path. -// Visited специально НЕ включаем. +// Only states that truly require slow-path invalidation handling. +// Visited is intentionally excluded. export const SLOW_INVALIDATION_MASK = - ReactiveNodeState.Invalid | - ReactiveNodeState.Changed | - ReactiveNodeState.Disposed | - ReactiveNodeState.Tracking; + ALREADY_DIRTY_MASK | + TERMINAL_MASK | + TRANSITIONAL_MASK; -export const VISITED_MASK = ReactiveNodeState.Visited; +export const VISITED_MASK = TRAVERSAL_GUARD_MASK; export const WATCHER_MASK = ReactiveNodeState.Watcher; -export const TRACKING_MASK = ReactiveNodeState.Tracking; -export const DISPOSED_MASK = ReactiveNodeState.Disposed; +export const TRACKING_MASK = TRANSITIONAL_MASK; +export const DISPOSED_MASK = TERMINAL_MASK; + +// Backward-compatible aliases for existing imports and tests. +export const NON_IMMEDIATE = PROMOTE_INVALID; +export const IMMEDIATE = PROMOTE_CHANGED; +export const CAN_ESCAPE_INVALIDATION = + ALREADY_DIRTY_MASK | + TERMINAL_MASK | + TRAVERSAL_GUARD_MASK | + TRANSITIONAL_MASK; diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts index 6963a6d..274fc4f 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -1,8 +1,9 @@ -import { recordDebugEvent } from "../../debug"; -import { defaultContext } from "../context"; +import { recordDebugEvent } from "../../debug.runtime"; +import { defaultContext, dispatchEffectInvalidated } from "../context"; import { devAssertPropagateAlive } from "../dev"; import type { ReactiveNode } from "../shape"; import { DIRTY_STATE, ReactiveNodeState } from "../shape"; +import { WATCHER_MASK } from "./propagate.constants"; export function propagateOnce(node: ReactiveNode): void { if ((node.state & ReactiveNodeState.Disposed) !== 0) { @@ -10,9 +11,8 @@ export function propagateOnce(node: ReactiveNode): void { return; } - const context = defaultContext; - const dispatch = context.effectInvalidatedDispatch; let thrown: unknown = null; + const dispatch = dispatchEffectInvalidated; for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { const sub = edge.to; @@ -20,22 +20,21 @@ export function propagateOnce(node: ReactiveNode): void { if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; - const nextState = - (state & ~ReactiveNodeState.Invalid) | ReactiveNodeState.Changed; + const nextState = state ^ DIRTY_STATE; sub.state = nextState; if (__DEV__) { - recordDebugEvent(context, "propagate", { + recordDebugEvent(defaultContext, "propagate", { detail: { immediate: true, nextState }, source: edge.from, target: sub, }); } - if ((nextState & ReactiveNodeState.Watcher) === 0) continue; + if ((nextState & WATCHER_MASK) === 0) continue; if (__DEV__) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); } if (dispatch !== undefined) { diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index f8abffc..c2c099f 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -1,5 +1,5 @@ -import { recordDebugEvent } from "../../debug"; -import { defaultContext } from "../context"; +import { recordDebugEvent } from "../../debug.runtime"; +import { defaultContext, dispatchEffectInvalidated } from "../context"; import { devAssertPropagateAlive } from "../dev"; import { DIRTY_STATE, @@ -20,8 +20,52 @@ import { // Resume points stay edge-based: we must come back to a specific sibling link, // and tracking checks depend on the current incoming edge identity. const propagateEdgeStack: ReactiveEdge[] = []; +const propagatePromoteStack: number[] = []; -function getInvalidatedSubscriberState( +function dispatchInvalidatedWatcher( + sub: ReactiveNode, + dispatch: typeof dispatchEffectInvalidated, + thrown: unknown, +): unknown { + if (dispatch !== undefined) { + try { + dispatch(sub); + } catch (error) { + if (thrown === null) { + return error; + } + } + } else if (__DEV__) { + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); + } + + return thrown; +} + +function getTrackingInvalidatedSubscriberState( + edge: ReactiveEdge, + sub: ReactiveNode, + subState: number, +): number { + const depsTail = sub.depsTail; + if (depsTail === null) return 0; + + const invalidatedState = subState | VISITED_MASK | ReactiveNodeState.Invalid; + if (edge === depsTail) return invalidatedState; + + const prevIn = edge.prevIn; + if (prevIn === null) return invalidatedState; + if (prevIn === depsTail) return 0; + + let cursor = prevIn.prevIn; + while (cursor !== null && cursor !== depsTail) { + cursor = cursor.prevIn; + } + + return cursor === depsTail ? 0 : invalidatedState; +} + +function getSlowInvalidatedSubscriberState( edge: ReactiveEdge, sub: ReactiveNode, subState: number, @@ -53,42 +97,48 @@ function getInvalidatedSubscriberState( return (subState & DIRTY_STATE) !== 0 ? 0 : promotedState; } -export function propagate( - startEdge: ReactiveEdge, - promoteImmediate: number = NON_IMMEDIATE, -): void { - const root = startEdge.from; - - if ((root.state & ReactiveNodeState.Disposed) !== 0) { - if (__DEV__) devAssertPropagateAlive(); - return; - } - - const dispatch = defaultContext.effectInvalidatedDispatch; +function propagateBranching( + edge: ReactiveEdge, + promote: number, + thrown: unknown, + parentResume: ReactiveEdge | null, + parentResumePromote: number, +): unknown { const edgeStack = propagateEdgeStack; + const promoteStack = propagatePromoteStack; const stackBase = edgeStack.length; let stackTop = stackBase; - let edge = startEdge; + let next: ReactiveEdge | null = edge.nextOut; + let nextPromote = promote; + const dispatch = dispatchEffectInvalidated; - let thrown: unknown = null; + if (parentResume !== null) { + edgeStack[stackTop] = parentResume; + promoteStack[stackTop++] = parentResumePromote; + } while (true) { const sub = edge.to; - const next = edge.nextOut; - const promoteBit = edge.from === root ? promoteImmediate : NON_IMMEDIATE; - const nextState = getInvalidatedSubscriberState( - edge, - sub, - sub.state, - promoteBit, - ); + const subState = sub.state; + let nextState: number; + + if ((subState & SLOW_INVALIDATION_MASK) === 0) { + nextState = (subState & ~VISITED_MASK) | promote; + } else if ((subState & TRACKING_MASK) !== 0) { + nextState = getTrackingInvalidatedSubscriberState(edge, sub, subState); + } else { + nextState = + (subState & (DIRTY_STATE | DISPOSED_MASK)) !== 0 + ? 0 + : (subState & ~VISITED_MASK) | promote; + } if (nextState !== 0) { sub.state = nextState; if (__DEV__) { recordDebugEvent(defaultContext, "propagate", { - detail: { immediate: promoteBit === IMMEDIATE, nextState }, + detail: { immediate: promote === IMMEDIATE, nextState }, source: edge.from, target: sub, }); @@ -96,39 +146,109 @@ export function propagate( if ((nextState & WATCHER_MASK) === 0) { const firstOut = sub.firstOut; - if (firstOut !== null) { - if (next !== null) edgeStack[stackTop++] = next; + if (next !== null) { + edgeStack[stackTop] = next; + promoteStack[stackTop++] = nextPromote; + } + edge = firstOut; + next = edge.nextOut; + promote = nextPromote = NON_IMMEDIATE; continue; } - } else if (dispatch !== undefined) { - try { - dispatch(sub); - } catch (error) { - if (thrown === null) { - thrown = error; - } - } - } else if (__DEV__) { - recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); + } else { + thrown = dispatchInvalidatedWatcher(sub, dispatch, thrown); } } if (next !== null) { edge = next; + promote = nextPromote; + next = edge.nextOut; continue; } if (stackTop !== stackBase) { edge = edgeStack[--stackTop]!; + promote = nextPromote = promoteStack[stackTop]!; + next = edge.nextOut; continue; } - break; + edgeStack.length = stackBase; + promoteStack.length = stackBase; + return thrown; + } +} + +function propagateLinear( + edge: ReactiveEdge, + promote: number, +): unknown { + let thrown: unknown = null; + const dispatch = dispatchEffectInvalidated; + + while (true) { + const sub = edge.to; + const next = edge.nextOut; + const subState = sub.state; + const nextState = + (subState & SLOW_INVALIDATION_MASK) === 0 + ? (subState & ~VISITED_MASK) | promote + : getSlowInvalidatedSubscriberState(edge, sub, subState, promote); + + if (nextState !== 0) { + sub.state = nextState; + + if (__DEV__) { + recordDebugEvent(defaultContext, "propagate", { + detail: { immediate: promote === IMMEDIATE, nextState }, + source: edge.from, + target: sub, + }); + } + + if ((nextState & WATCHER_MASK) === 0) { + const firstOut = sub.firstOut; + if (firstOut !== null) { + edge = firstOut; + + if (next !== null) { + return propagateBranching( + edge, + NON_IMMEDIATE, + thrown, + next, + promote, + ); + } + + promote = NON_IMMEDIATE; + continue; + } + } else { + thrown = dispatchInvalidatedWatcher(sub, dispatch, thrown); + } + } + + if (next === null) return thrown; + edge = next; + } +} + +export function propagate( + startEdge: ReactiveEdge, + promoteImmediate: number = NON_IMMEDIATE, +): void { + const root = startEdge.from; + + if ((root.state & ReactiveNodeState.Disposed) !== 0) { + if (__DEV__) devAssertPropagateAlive(); + return; } - edgeStack.length = stackBase; + const thrown = propagateLinear(startEdge, promoteImmediate); if (thrown !== null) throw thrown; } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts index cd6882d..872b822 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts @@ -15,14 +15,114 @@ import type { ReactiveNode, ReactiveEdge } from "../shape"; import { ReactiveNodeState, DIRTY_STATE } from "../shape"; -import { shouldRecomputeBranching } from "./recompute.branching"; -import { refreshRecompute } from "./recompute.refresh"; +import { recompute } from "../engine/compute"; +import { propagateOnce } from "./propagate.once"; // Shared stack — reused across calls to avoid allocation. // stackBase tracks the logical bottom per call so recursive entries // don't trample each other's frames. const shouldRecomputeStack: ReactiveEdge[] = []; +function refreshRecompute(node: ReactiveNode): boolean { + return recompute(node); +} + +function hasFanout(edge: ReactiveEdge): boolean { + return edge.prevOut !== null || edge.nextOut !== null; +} + +function refreshAndPropagateIfFanout( + node: ReactiveNode, + fanout: boolean, +): boolean { + const changed = refreshRecompute(node); + + if (changed && fanout) { + propagateOnce(node); + } + + return changed; +} + +function refreshAndPropagateIfNeeded( + node: ReactiveNode, + fanout: boolean, +): boolean { + const changed = refreshRecompute(node); + + if (changed && fanout) { + propagateOnce(node); + } + + return changed; +} + +function shouldRecomputeBranching( + link: ReactiveEdge, + consumer: ReactiveNode, + stack: ReactiveEdge[], + stackTop: number, + stackBase: number, +): boolean { + let changed = false; + + outer: while (true) { + if ((consumer.state & ReactiveNodeState.Changed) !== 0) { + changed = true; + } else { + const dep = link.from; + const depState = dep.state; + + if ((depState & ReactiveNodeState.Changed) !== 0) { + // Already-confirmed computed dependency: refresh and stop searching. + changed = refreshAndPropagateIfNeeded(dep, hasFanout(link)); + } else if ((depState & DIRTY_STATE) !== 0) { + const deps = dep.firstIn; + if (deps !== null) { + stack[stackTop++] = link; + link = deps; + consumer = dep; + continue; + } + + changed = refreshAndPropagateIfNeeded(dep, hasFanout(link)); + } + } + + if (!changed) { + const next = link.nextIn; + if (next !== null) { + link = next; + continue; + } + consumer.state &= ~ReactiveNodeState.Invalid; + } + + while (stackTop > stackBase) { + const parentLink = stack[--stackTop]!; + const parentFanout = hasFanout(parentLink); + + if (changed) { + changed = refreshAndPropagateIfNeeded(consumer, parentFanout); + } else { + consumer.state &= ~ReactiveNodeState.Invalid; + } + + consumer = parentLink.to; + + if (!changed) { + const next = parentLink.nextIn; + if (next !== null) { + link = next; + continue outer; + } + } + } + + return changed; + } +} + // so the finally was only cleanup — moving it inline is correct. export function shouldRecomputeLinear( node: ReactiveNode, @@ -36,7 +136,9 @@ export function shouldRecomputeLinear( let changed = false; while (true) { - if (link.nextIn !== null) { + const nextIn = link.nextIn; + + if (nextIn !== null) { // Multiple deps at this level: hand off to DFS. // Stack ownership transfers — branching will pop down to stackBase. return shouldRecomputeBranching( @@ -57,9 +159,8 @@ export function shouldRecomputeLinear( const depState = dep.state; if ((depState & ReactiveNodeState.Changed) !== 0) { - changed = refreshRecompute(link, dep); - - if (changed || link.nextIn === null) break; + changed = refreshAndPropagateIfFanout(dep, hasFanout(link)); + break; } if ((depState & DIRTY_STATE) !== 0) { @@ -89,7 +190,7 @@ export function shouldRecomputeLinear( continue; } - changed = refreshRecompute(link, dep); + changed = refreshAndPropagateIfFanout(dep, hasFanout(link)); break; } @@ -98,6 +199,7 @@ export function shouldRecomputeLinear( if (stackTop === stackBase) { // Stack empty: nothing changed anymore. + stack.length = stackBase; return false; } @@ -108,9 +210,10 @@ export function shouldRecomputeLinear( // Unwind: propagate the change (or clean) decision up the stack. while (stackTop > stackBase) { const parentLink = stack[--stackTop]!; + const parentFanout = hasFanout(parentLink); if (changed) { - changed = refreshRecompute(parentLink, consumer); + changed = refreshAndPropagateIfNeeded(consumer, parentFanout); } else { consumer.state &= ~ReactiveNodeState.Invalid; } @@ -124,4 +227,3 @@ export function shouldRecomputeLinear( stack.length = stackBase; return changed; } - diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts deleted file mode 100644 index 5897ac3..0000000 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts +++ /dev/null @@ -1,75 +0,0 @@ -// ─── shouldRecomputeBranching ───────────────────────────────────────────────── -// -// DFS with explicit stack for nodes that have multiple incoming edges. -// Too large to inline — that's intentional. The linear fast-path avoids -// calling this at all for the common single-dependency chain. -// -// Stack discipline: entries are pushed before descending into a subtree and -// popped during backtrack. stackBase marks the logical bottom for this -// activation so nested re-entrant calls don't see each other's frames. - -import type { ReactiveEdge, ReactiveNode } from "../shape"; -import { ReactiveNodeState, DIRTY_STATE } from "../shape"; -import { refreshRecompute } from "./recompute.refresh"; - -export function shouldRecomputeBranching( - link: ReactiveEdge, - consumer: ReactiveNode, - stack: ReactiveEdge[], - stackTop: number, - stackBase: number, -): boolean { - let changed = false; - - outer: while (true) { - const dep = link.from; - const depState = dep.state; - - if ((consumer.state & ReactiveNodeState.Changed) !== 0) { - changed = true; - } else if ((depState & ReactiveNodeState.Changed) !== 0) { - // Already-confirmed computed dependency: refresh and stop searching. - changed = refreshRecompute(link, dep); - } else if ((depState & DIRTY_STATE) !== 0) { - const deps = dep.firstIn; - if (deps !== null) { - stack[stackTop++] = link; - link = deps; - consumer = dep; - continue; - } - changed = refreshRecompute(link, dep); - } - - if (!changed) { - const next = link.nextIn; - if (next !== null) { - link = next; - continue; - } - consumer.state &= ~ReactiveNodeState.Invalid; - } - - while (stackTop > stackBase) { - const parentLink = stack[--stackTop]!; - - if (changed) { - changed = refreshRecompute(parentLink, consumer); - } else { - consumer.state &= ~ReactiveNodeState.Invalid; - } - - consumer = parentLink.to; - - if (!changed) { - const next = parentLink.nextIn; - if (next !== null) { - link = next; - continue outer; - } - } - } - - return changed; - } -} diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.ts index 36068eb..24cc997 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.ts @@ -5,6 +5,50 @@ import { shouldRecomputeLinear } from "./recompute.branch"; const STOP_RECOMPUTE = ReactiveNodeState.Producer | ReactiveNodeState.Disposed; const REENTRANT_STALE = ReactiveNodeState.Invalid | ReactiveNodeState.Visited; +const WATCHER_REENTRANT_STALE = + ReactiveNodeState.Invalid | ReactiveNodeState.Visited; + +export function shouldRecomputeDirtyConsumer( + node: ReactiveNode, + state: number, +): boolean { + if ((state & ReactiveNodeState.Changed) !== 0) { + return true; + } + + if ((state & REENTRANT_STALE) === REENTRANT_STALE) { + return true; + } + + const firstIn = node.firstIn; + if (firstIn === null) { + node.state = state & ~ReactiveNodeState.Invalid; + return false; + } + + return shouldRecomputeLinear(node, firstIn); +} + +export function shouldRecomputeDirtyWatcher( + node: ReactiveNode, + state: number, +): boolean { + if ((state & ReactiveNodeState.Changed) !== 0) { + return true; + } + + if ((state & WATCHER_REENTRANT_STALE) === WATCHER_REENTRANT_STALE) { + return true; + } + + const firstIn = node.firstIn; + if (firstIn === null) { + node.state = state & ~ReactiveNodeState.Invalid; + return false; + } + + return shouldRecomputeLinear(node, firstIn); +} // Entry point. Kept small so TurboFan/Ion/DFG eagerly inline it into callers. // All early-exit checks come first so the common fast paths never touch diff --git a/packages/@reflex/runtime/src/subtle.ts b/packages/@reflex/runtime/src/subtle.ts index 8a31c54..01455cb 100644 --- a/packages/@reflex/runtime/src/subtle.ts +++ b/packages/@reflex/runtime/src/subtle.ts @@ -6,16 +6,18 @@ import { readDebugHistory, snapshotDebugContext, snapshotDebugNode, - type RuntimeDebugContextSnapshot, - type RuntimeDebugEvent, - type RuntimeDebugListener, - type RuntimeDebugNodeSnapshot, - type RuntimeDebugOptions, -} from "./debug"; +} from "./debug.runtime"; import { getCurrentComputedInternal } from "./internal"; import { getDefaultContext } from "./reactivity/context"; import type { ExecutionContext } from "./reactivity/context"; import type { ReactiveNode } from "./reactivity/shape"; +import type { + RuntimeDebugContextSnapshot, + RuntimeDebugEvent, + RuntimeDebugListener, + RuntimeDebugNodeSnapshot, + RuntimeDebugOptions, +} from "./debug.types"; const noopUnsubscribe = () => {}; const IS_DEV = typeof __DEV__ !== "undefined" && __DEV__; diff --git a/packages/@reflex/runtime/tests/perf/propagate-stack-compare.jit.mjs b/packages/@reflex/runtime/tests/perf/propagate-stack-compare.jit.mjs index edc1d38..6da27e7 100644 --- a/packages/@reflex/runtime/tests/perf/propagate-stack-compare.jit.mjs +++ b/packages/@reflex/runtime/tests/perf/propagate-stack-compare.jit.mjs @@ -11,6 +11,7 @@ import { UNINITIALIZED } from "../../build/esm/reactivity/shape/ReactiveNode.js" import ReactiveNode from "../../build/esm/reactivity/shape/ReactiveNode.js"; import { linkEdge } from "../../build/esm/reactivity/shape/methods/connect.js"; import { propagate as propagateImported } from "../../build/esm/reactivity/walkers/propagate.js"; +import { PROMOTE_CHANGED } from "../../build/esm/reactivity/walkers/propagate.constants.js"; const runtime = getDefaultContext(); @@ -82,6 +83,103 @@ function notifyWatcherInvalidation(node, thrown, context) { return thrown; } +function createProfileCounters() { + return { + edgeVisits: 0, + linearBranchHandoffs: 0, + branchingEntries: 0, + linearReentriesAfterBranching: 0, + linearEdgeVisits: 0, + branchingEdgeVisits: 0, + firstBranchRuns: 0, + linearEdgesBeforeFirstBranch: 0, + linearTimeMs: 0, + branchingTimeMs: 0, + slowPathHits: 0, + trackingSlowPathHits: 0, + trackingFallbackScans: 0, + trackingFallbackScanSteps: 0, + stackPushes: 0, + stackPops: 0, + }; +} + +function resetProfileCounters(counters) { + counters.edgeVisits = 0; + counters.linearBranchHandoffs = 0; + counters.branchingEntries = 0; + counters.linearReentriesAfterBranching = 0; + counters.linearEdgeVisits = 0; + counters.branchingEdgeVisits = 0; + counters.firstBranchRuns = 0; + counters.linearEdgesBeforeFirstBranch = 0; + counters.linearTimeMs = 0; + counters.branchingTimeMs = 0; + counters.slowPathHits = 0; + counters.trackingSlowPathHits = 0; + counters.trackingFallbackScans = 0; + counters.trackingFallbackScanSteps = 0; + counters.stackPushes = 0; + counters.stackPops = 0; +} + +function snapshotProfileCounters(counters) { + return { ...counters }; +} + +function getSlowInvalidatedSubscriberStateProfiled( + edge, + state, + promoteImmediate, + counters, +) { + counters.slowPathHits += 1; + + if ((state & (DIRTY_STATE | ReactiveNodeState.Disposed)) !== 0) return 0; + + if ((state & ReactiveNodeState.Tracking) === 0) { + return ( + (state & ~ReactiveNodeState.Visited) | + (promoteImmediate ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) + ); + } + + counters.trackingSlowPathHits += 1; + + const depsTail = edge.to.depsTail; + if (depsTail === null) return 0; + if (edge === depsTail) { + return state | ReactiveNodeState.Visited | ReactiveNodeState.Invalid; + } + + const prevIn = edge.prevIn; + if (prevIn === null) { + return state | ReactiveNodeState.Visited | ReactiveNodeState.Invalid; + } + if (prevIn === depsTail) return 0; + + counters.trackingFallbackScans += 1; + + let cursor = prevIn.prevIn; + while (cursor !== null && cursor !== depsTail) { + counters.trackingFallbackScanSteps += 1; + cursor = cursor.prevIn; + } + + return cursor === depsTail + ? 0 + : state | ReactiveNodeState.Visited | ReactiveNodeState.Invalid; +} + +function getNextStateProfiled(edge, state, promote, counters) { + counters.edgeVisits += 1; + + return (state & INVALIDATION_SLOW_PATH_MASK) === 0 + ? state | + (promote ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) + : getSlowInvalidatedSubscriberStateProfiled(edge, state, promote, counters); +} + function createPropagateArrayVariant() { const edgeStack = []; const promoteStack = []; @@ -199,6 +297,155 @@ function createPropagateArrayVariant() { }; } +function createPropagateSplitProfileVariant() { + const edgeStack = []; + const promoteStack = []; + const counters = createProfileCounters(); + let currentRunSawFirstBranch = false; + + function propagateBranching( + edge, + promote, + thrown, + parentResume, + parentResumePromote, + context, + ) { + const stackBase = edgeStack.length; + let stackTop = stackBase; + let resume = edge.nextOut; + let resumePromote = promote; + let localBranchingEdges = 0; + const startedAt = performance.now(); + + counters.branchingEntries += 1; + + if (parentResume !== null) { + edgeStack[stackTop] = parentResume; + promoteStack[stackTop++] = parentResumePromote; + counters.stackPushes += 1; + } + + try { + while (true) { + const sub = edge.to; + const state = sub.state; + localBranchingEdges += 1; + const nextState = getNextStateProfiled(edge, state, promote, counters); + + if (nextState !== 0) { + sub.state = nextState; + + if ((nextState & ReactiveNodeState.Watcher) !== 0) { + thrown = notifyWatcherInvalidation(sub, thrown, context); + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + if (resume !== null) { + edgeStack[stackTop] = resume; + promoteStack[stackTop++] = resumePromote; + counters.stackPushes += 1; + } + edge = firstOut; + resume = edge.nextOut; + promote = resumePromote = NON_IMMEDIATE; + continue; + } + } + } + + if (resume !== null) { + edge = resume; + promote = resumePromote; + resume = edge.nextOut; + } else if (stackTop > stackBase) { + --stackTop; + counters.stackPops += 1; + edge = edgeStack[stackTop]; + promote = resumePromote = promoteStack[stackTop]; + resume = edge.nextOut; + } else { + return thrown; + } + } + } finally { + counters.branchingEdgeVisits += localBranchingEdges; + counters.branchingTimeMs += performance.now() - startedAt; + edgeStack.length = stackBase; + promoteStack.length = stackBase; + } + } + + function propagateLinear(edge, promote, thrown, context) { + let localLinearEdges = 0; + const startedAt = performance.now(); + + try { + while (true) { + const sub = edge.to; + const state = sub.state; + localLinearEdges += 1; + const nextState = getNextStateProfiled(edge, state, promote, counters); + const next = edge.nextOut; + + if (nextState !== 0) { + sub.state = nextState; + + if ((nextState & ReactiveNodeState.Watcher) !== 0) { + thrown = notifyWatcherInvalidation(sub, thrown, context); + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + edge = firstOut; + if (next !== null) { + counters.linearBranchHandoffs += 1; + if (!currentRunSawFirstBranch) { + currentRunSawFirstBranch = true; + counters.firstBranchRuns += 1; + counters.linearEdgesBeforeFirstBranch += localLinearEdges; + } + + return propagateBranching( + edge, + NON_IMMEDIATE, + thrown, + next, + promote, + context, + ); + } + promote = NON_IMMEDIATE; + continue; + } + } + } + + if (next === null) return thrown; + edge = next; + } + } finally { + counters.linearEdgeVisits += localLinearEdges; + counters.linearTimeMs += performance.now() - startedAt; + } + } + + function propagateArrayProfileVariant( + startEdge, + promoteImmediate = NON_IMMEDIATE, + context = runtime, + ) { + if ((startEdge.from.state & ReactiveNodeState.Disposed) !== 0) return; + currentRunSawFirstBranch = false; + + const thrown = propagateLinear(startEdge, promoteImmediate, null, context); + if (thrown !== null) throw thrown; + } + + propagateArrayProfileVariant.resetProfile = () => resetProfileCounters(counters); + propagateArrayProfileVariant.readProfile = () => snapshotProfileCounters(counters); + return propagateArrayProfileVariant; +} + function createPropagateInt32Variant() { const edgeStack = []; let promoteStack = new Int32Array(16); @@ -327,8 +574,156 @@ function createPropagateInt32Variant() { }; } +function createPropagateHybridVariant() { + const edgeStack = []; + const promoteStack = []; + + return function propagateHybridVariant( + startEdge, + promoteImmediate = NON_IMMEDIATE, + context = runtime, + ) { + if ((startEdge.from.state & ReactiveNodeState.Disposed) !== 0) return; + + const stackBase = edgeStack.length; + let stackTop = stackBase; + let edge = startEdge; + let promote = promoteImmediate; + let thrown = null; + + try { + while (edge !== null) { + const sub = edge.to; + const next = edge.nextOut; + const state = sub.state; + const nextState = + (state & INVALIDATION_SLOW_PATH_MASK) === 0 + ? state | + (promote ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) + : getSlowInvalidatedSubscriberState(edge, state, promote); + + if (nextState !== 0) { + sub.state = nextState; + + if ((nextState & ReactiveNodeState.Watcher) !== 0) { + thrown = notifyWatcherInvalidation(sub, thrown, context); + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + if (next !== null) { + edgeStack[stackTop] = next; + promoteStack[stackTop++] = promote; + } + + edge = firstOut; + promote = NON_IMMEDIATE; + continue; + } + } + } + + if (next !== null) { + edge = next; + continue; + } + + if (stackTop > stackBase) { + --stackTop; + edge = edgeStack[stackTop]; + promote = promoteStack[stackTop]; + continue; + } + + edge = null; + } + } finally { + edgeStack.length = stackBase; + promoteStack.length = stackBase; + } + + if (thrown !== null) throw thrown; + }; +} + +function createPropagateHybridProfileVariant() { + const edgeStack = []; + const promoteStack = []; + const counters = createProfileCounters(); + + function propagateHybridProfileVariant( + startEdge, + promoteImmediate = NON_IMMEDIATE, + context = runtime, + ) { + if ((startEdge.from.state & ReactiveNodeState.Disposed) !== 0) return; + + const stackBase = edgeStack.length; + let stackTop = stackBase; + let edge = startEdge; + let promote = promoteImmediate; + let thrown = null; + + try { + while (edge !== null) { + const sub = edge.to; + const next = edge.nextOut; + const state = sub.state; + const nextState = getNextStateProfiled(edge, state, promote, counters); + + if (nextState !== 0) { + sub.state = nextState; + + if ((nextState & ReactiveNodeState.Watcher) !== 0) { + thrown = notifyWatcherInvalidation(sub, thrown, context); + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + if (next !== null) { + edgeStack[stackTop] = next; + promoteStack[stackTop++] = promote; + counters.stackPushes += 1; + } + + edge = firstOut; + promote = NON_IMMEDIATE; + continue; + } + } + } + + if (next !== null) { + edge = next; + continue; + } + + if (stackTop > stackBase) { + --stackTop; + counters.stackPops += 1; + edge = edgeStack[stackTop]; + promote = promoteStack[stackTop]; + continue; + } + + edge = null; + } + } finally { + edgeStack.length = stackBase; + promoteStack.length = stackBase; + } + + if (thrown !== null) throw thrown; + } + + propagateHybridProfileVariant.resetProfile = () => resetProfileCounters(counters); + propagateHybridProfileVariant.readProfile = () => snapshotProfileCounters(counters); + return propagateHybridProfileVariant; +} + const propagateArrayLocal = createPropagateArrayVariant(); +const propagateSplitProfile = createPropagateSplitProfileVariant(); const propagateInt32Local = createPropagateInt32Variant(); +const propagateHybridLocal = createPropagateHybridVariant(); +const propagateHybridProfile = createPropagateHybridProfileVariant(); function buildPropagateChain(depth) { resetRuntime(); @@ -347,9 +742,40 @@ function buildPropagateChain(depth) { const startEdge = root.firstOut; if (startEdge === null) throw new Error("propagate chain root has no edge"); + function baseline() { + clearWalkerState(nodes); + return nodes.length; + } + return { - run(propagateImpl) { - propagateImpl(startEdge, IMMEDIATE, runtime); + baseline, + imported() { + propagateImported(startEdge, PROMOTE_CHANGED); + clearWalkerState(nodes); + return nodes.length; + }, + arrayLocal() { + propagateArrayLocal(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + int32Local() { + propagateInt32Local(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + hybridLocal() { + propagateHybridLocal(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + splitProfile() { + propagateSplitProfile(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + hybridProfile() { + propagateHybridProfile(startEdge, IMMEDIATE, runtime); clearWalkerState(nodes); return nodes.length; }, @@ -378,9 +804,40 @@ function buildPropagateFanout(width, depth) { const startEdge = root.firstOut; if (startEdge === null) throw new Error("propagate fanout root has no edge"); + function baseline() { + clearWalkerState(nodes); + return nodes.length; + } + return { - run(propagateImpl) { - propagateImpl(startEdge, IMMEDIATE, runtime); + baseline, + imported() { + propagateImported(startEdge, PROMOTE_CHANGED); + clearWalkerState(nodes); + return nodes.length; + }, + arrayLocal() { + propagateArrayLocal(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + int32Local() { + propagateInt32Local(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + hybridLocal() { + propagateHybridLocal(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + splitProfile() { + propagateSplitProfile(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + hybridProfile() { + propagateHybridProfile(startEdge, IMMEDIATE, runtime); clearWalkerState(nodes); return nodes.length; }, @@ -409,15 +866,111 @@ function buildPropagateTree(branching, depth) { const startEdge = root.firstOut; if (startEdge === null) throw new Error("propagate tree root has no edge"); + function baseline() { + clearWalkerState(nodes); + return nodes.length; + } + return { - run(propagateImpl) { - propagateImpl(startEdge, IMMEDIATE, runtime); + baseline, + imported() { + propagateImported(startEdge, PROMOTE_CHANGED); + clearWalkerState(nodes); + return nodes.length; + }, + arrayLocal() { + propagateArrayLocal(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + int32Local() { + propagateInt32Local(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + hybridLocal() { + propagateHybridLocal(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + splitProfile() { + propagateSplitProfile(startEdge, IMMEDIATE, runtime); + clearWalkerState(nodes); + return nodes.length; + }, + hybridProfile() { + propagateHybridProfile(startEdge, IMMEDIATE, runtime); clearWalkerState(nodes); return nodes.length; }, }; } +function buildTrackedPrefixStress(fanIn, depsTailIndex, edgeIndex) { + resetRuntime(); + + const target = createConsumer(() => 0); + const edges = []; + + for (let i = 0; i < fanIn; i += 1) { + const producer = createProducer(i); + edges.push(linkEdge(producer, target)); + } + + const depsTail = edges[depsTailIndex]; + const targetEdge = edges[edgeIndex]; + + if (!depsTail || !targetEdge) { + throw new Error("tracked-prefix stress graph is incomplete"); + } + + function baseline() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + return target.state; + } + + return { + baseline, + imported() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateImported(targetEdge, PROMOTE_CHANGED); + return target.state; + }, + arrayLocal() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateArrayLocal(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + int32Local() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateInt32Local(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + hybridLocal() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateHybridLocal(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + splitProfile() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateSplitProfile(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + hybridProfile() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateHybridProfile(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + }; +} + function buildPropagateBranchingTrackingMix(width, depth) { resetRuntime(); @@ -483,9 +1036,38 @@ function buildPropagateBranchingTrackingMix(width, depth) { } return { - run(propagateImpl) { + baseline() { + armTracking(); + return nodes.length; + }, + imported() { + armTracking(); + propagateImported(startEdge, PROMOTE_CHANGED); + return nodes.length; + }, + arrayLocal() { + armTracking(); + propagateArrayLocal(startEdge, IMMEDIATE, runtime); + return nodes.length; + }, + int32Local() { + armTracking(); + propagateInt32Local(startEdge, IMMEDIATE, runtime); + return nodes.length; + }, + hybridLocal() { + armTracking(); + propagateHybridLocal(startEdge, IMMEDIATE, runtime); + return nodes.length; + }, + splitProfile() { + armTracking(); + propagateSplitProfile(startEdge, IMMEDIATE, runtime); + return nodes.length; + }, + hybridProfile() { armTracking(); - propagateImpl(startEdge, IMMEDIATE, runtime); + propagateHybridProfile(startEdge, IMMEDIATE, runtime); return nodes.length; }, }; @@ -518,6 +1100,21 @@ function measure(fn, iterations) { }; } +function collectProfile(run, variant, iterations) { + variant.resetProfile(); + + let sink = 0; + for (let i = 0; i < iterations; i += 1) { + sink ^= run(i) & 1; + } + + return { + iterations, + sink, + ...variant.readProfile(), + }; +} + function median(values) { const sorted = [...values].sort((a, b) => a - b); return sorted[(sorted.length / 2) | 0]; @@ -529,21 +1126,41 @@ function formatDelta(candidate, baseline) { return `${sign}${delta.toFixed(1)}%`; } +function formatPerOp(count, iterations) { + return (count / iterations).toFixed(2); +} + +function formatRate(count, total) { + if (total === 0) return "0.0%"; + return `${((count / total) * 100).toFixed(1)}%`; +} + +function printProfileLine(label, profile) { + const linearTimeTotal = profile.linearTimeMs + profile.branchingTimeMs; + console.log( + ` ${label}: edges/op=${formatPerOp(profile.edgeVisits, profile.iterations)} | handoffs/op=${formatPerOp(profile.linearBranchHandoffs, profile.iterations)} | branching_entries/op=${formatPerOp(profile.branchingEntries, profile.iterations)} | linear_reentries_after_branch/op=${formatPerOp(profile.linearReentriesAfterBranching, profile.iterations)} | linear_before_first_branch/op=${formatPerOp(profile.linearEdgesBeforeFirstBranch, profile.firstBranchRuns || 1)} | linear_edge_share=${formatRate(profile.linearEdgeVisits, profile.edgeVisits)} | linear_time_share=${formatRate(profile.linearTimeMs, linearTimeTotal)} | pushes/op=${formatPerOp(profile.stackPushes, profile.iterations)} | pops/op=${formatPerOp(profile.stackPops, profile.iterations)} | slow/op=${formatPerOp(profile.slowPathHits, profile.iterations)} (${formatRate(profile.slowPathHits, profile.edgeVisits)}) | tracking_slow/op=${formatPerOp(profile.trackingSlowPathHits, profile.iterations)} | tracking_scans/op=${formatPerOp(profile.trackingFallbackScans, profile.iterations)} | scan_steps/op=${formatPerOp(profile.trackingFallbackScanSteps, profile.iterations)}`, + ); +} + function runScenario(label, scenario, iterations, warmup = iterations >> 1, rounds = 7) { const variants = [ - ["imported", () => scenario.run(propagateImported)], - ["array_local", () => scenario.run(propagateArrayLocal)], - ["int32_local", () => scenario.run(propagateInt32Local)], + ["imported", scenario.imported], + ["array_local", scenario.arrayLocal], + ["int32_local", scenario.int32Local], + ["hybrid_local", scenario.hybridLocal], ]; const samples = new Map(); + const baselineSamples = []; + warm(scenario.baseline, warmup); for (const [name, fn] of variants) { warm(fn, warmup); samples.set(name, []); } for (let round = 0; round < rounds; round += 1) { - const order = round % 3; + baselineSamples.push(measure(scenario.baseline, iterations).nsPerOp); + const order = round % variants.length; for (let offset = 0; offset < variants.length; offset += 1) { const [name, fn] = variants[(order + offset) % variants.length]; const result = measure(fn, iterations); @@ -551,26 +1168,60 @@ function runScenario(label, scenario, iterations, warmup = iterations >> 1, roun } } - const importedMedian = median(samples.get("imported")); - const arrayMedian = median(samples.get("array_local")); - const int32Median = median(samples.get("int32_local")); + const baselineMedian = median(baselineSamples); + const importedMedianRaw = median(samples.get("imported")); + const arrayMedianRaw = median(samples.get("array_local")); + const int32MedianRaw = median(samples.get("int32_local")); + const hybridMedianRaw = median(samples.get("hybrid_local")); + const importedMedian = importedMedianRaw - baselineMedian; + const arrayMedian = arrayMedianRaw - baselineMedian; + const int32Median = int32MedianRaw - baselineMedian; + const hybridMedian = hybridMedianRaw - baselineMedian; + const profileIterations = Math.min(iterations, 256); + const splitProfile = collectProfile( + scenario.splitProfile, + propagateSplitProfile, + profileIterations, + ); + const hybridProfile = collectProfile( + scenario.hybridProfile, + propagateHybridProfile, + profileIterations, + ); console.log(`\n${label}`); console.log( - ` imported: ${importedMedian.toFixed(1)} ns/op`, + ` baseline: ${baselineMedian.toFixed(1)} ns/op`, ); console.log( - ` array_local: ${arrayMedian.toFixed(1)} ns/op (${formatDelta( + ` imported: ${importedMedian.toFixed(1)} ns/op adj (${importedMedianRaw.toFixed(1)} raw)`, + ); + console.log( + ` array_local: ${arrayMedian.toFixed(1)} ns/op adj (${arrayMedianRaw.toFixed(1)} raw, ${formatDelta( arrayMedian, importedMedian, )} vs imported)`, ); console.log( - ` int32_local: ${int32Median.toFixed(1)} ns/op (${formatDelta( + ` int32_local: ${int32Median.toFixed(1)} ns/op adj (${int32MedianRaw.toFixed(1)} raw, ${formatDelta( + int32Median, + importedMedian, + )} vs imported)`, + ); + console.log( + ` hybrid_local: ${hybridMedian.toFixed(1)} ns/op adj (${hybridMedianRaw.toFixed(1)} raw, ${formatDelta( + hybridMedian, + importedMedian, + )} vs imported)`, + ); + console.log( + ` int32 vs array: ${formatDelta( int32Median, arrayMedian, - )} vs array_local)`, + )}`, ); + printProfileLine("split_profile", splitProfile); + printProfileLine("hybrid_profile", hybridProfile); } function main() { @@ -578,6 +1229,18 @@ function main() { runScenario("propagate_fanout_32x8", buildPropagateFanout(32, 8), 100000); runScenario("propagate_tree_4x5", buildPropagateTree(4, 5), 20000, 10000); runScenario("propagate_tree_4x6", buildPropagateTree(4, 6), 5000, 2500); + runScenario( + "tracked_prefix_scan_true_1024_768_767", + buildTrackedPrefixStress(1024, 768, 767), + 50000, + 25000, + ); + runScenario( + "tracked_prefix_scan_false_1024_31_1023", + buildTrackedPrefixStress(1024, 31, 1023), + 50000, + 25000, + ); runScenario( "propagate_branching_tracking_mix_32x8", buildPropagateBranchingTrackingMix(32, 8), diff --git a/packages/@reflex/runtime/tests/perf/repeated-read-branching.jit.mjs b/packages/@reflex/runtime/tests/perf/repeated-read-branching.jit.mjs new file mode 100644 index 0000000..660269a --- /dev/null +++ b/packages/@reflex/runtime/tests/perf/repeated-read-branching.jit.mjs @@ -0,0 +1,197 @@ +import { performance } from "node:perf_hooks"; +import { + CONSUMER_INITIAL_STATE, + PRODUCER_INITIAL_STATE, + ReactiveNode, + WATCHER_INITIAL_STATE, + readConsumer, + readProducer, + resetDefaultContext, + runWatcher, + writeProducer, +} from "../../build/esm/index.js"; +import { UNINITIALIZED } from "../../build/esm/reactivity/shape/ReactiveNode.js"; + +function createProducer(value) { + return new ReactiveNode(value, null, PRODUCER_INITIAL_STATE); +} + +function createConsumer(compute) { + return new ReactiveNode(UNINITIALIZED, compute, CONSUMER_INITIAL_STATE); +} + +function createWatcher(compute) { + return new ReactiveNode(UNINITIALIZED, compute, WATCHER_INITIAL_STATE); +} + +function warm(fn, iterations) { + let sink = 0; + + for (let i = 0; i < iterations; i += 1) { + sink ^= fn(i) & 1; + } + + return sink; +} + +function bench(label, fn, iterations, warmup = iterations) { + warm(fn, warmup); + + if (globalThis.gc) globalThis.gc(); + + let sink = 0; + const start = performance.now(); + + for (let i = 0; i < iterations; i += 1) { + sink ^= fn(i) & 1; + } + + const elapsedMs = performance.now() - start; + const nsPerWrite = (elapsedMs * 1e6) / iterations; + console.log(`${label}: ${nsPerWrite.toFixed(1)} ns/write | sink=${sink}`); +} + +function setupScenario(kind) { + resetDefaultContext(); + + const head = createProducer(0); + const double = createConsumer(() => readProducer(head) * 2); + const inverse = createConsumer(() => -readProducer(head)); + + const current = createConsumer(() => { + let result = 0; + + if (kind === "repeated_reads") { + for (let i = 0; i < 20; i += 1) { + result += readProducer(head) % 2 + ? readConsumer(double) + : readConsumer(inverse); + } + return result; + } + + const value = readProducer(head); + + if (kind === "cached_head") { + for (let i = 0; i < 20; i += 1) { + result += value % 2 ? readConsumer(double) : readConsumer(inverse); + } + return result; + } + + const branch = value % 2 ? readConsumer(double) : readConsumer(inverse); + for (let i = 0; i < 20; i += 1) { + result += branch; + } + + return result; + }); + + let effectRuns = 0; + const effect = createWatcher(() => { + readConsumer(current); + effectRuns += 1; + }); + + runWatcher(effect); + + return { + run(iteration) { + writeProducer(head, iteration); + runWatcher(effect); + return readConsumer(current) + effectRuns; + }, + }; +} + +function runSuite() { + const iterations = 20000; + const warmup = 5000; + + bench( + "branching_repeated_reads", + (() => { + const scenario = setupScenario("repeated_reads"); + return (i) => scenario.run(i); + })(), + iterations, + warmup, + ); + + bench( + "branching_cached_head", + (() => { + const scenario = setupScenario("cached_head"); + return (i) => scenario.run(i); + })(), + iterations, + warmup, + ); + + bench( + "branching_cached_head_and_branch", + (() => { + const scenario = setupScenario("cached_head_and_branch"); + return (i) => scenario.run(i); + })(), + iterations, + warmup, + ); + + bench( + "duplicate_read_single_source", + (() => { + resetDefaultContext(); + + const head = createProducer(0); + const current = createConsumer(() => { + let result = 0; + for (let i = 0; i < 20; i += 1) { + result += readProducer(head); + } + return result; + }); + const effect = createWatcher(() => readConsumer(current)); + + runWatcher(effect); + + return (i) => { + writeProducer(head, i); + runWatcher(effect); + return readConsumer(current); + }; + })(), + iterations, + warmup, + ); + + bench( + "duplicate_read_single_source_cached", + (() => { + resetDefaultContext(); + + const head = createProducer(0); + const current = createConsumer(() => { + const value = readProducer(head); + let result = 0; + for (let i = 0; i < 20; i += 1) { + result += value; + } + return result; + }); + const effect = createWatcher(() => readConsumer(current)); + + runWatcher(effect); + + return (i) => { + writeProducer(head, i); + runWatcher(effect); + return readConsumer(current); + }; + })(), + iterations, + warmup, + ); +} + +runSuite(); diff --git a/packages/@reflex/runtime/tests/perf/walkers.jit.mjs b/packages/@reflex/runtime/tests/perf/walkers.jit.mjs index 450a749..1d7a5ea 100644 --- a/packages/@reflex/runtime/tests/perf/walkers.jit.mjs +++ b/packages/@reflex/runtime/tests/perf/walkers.jit.mjs @@ -1,9 +1,11 @@ import { performance } from "node:perf_hooks"; +import { hrtime } from "node:process"; import { ConsumerReadMode, readConsumer, readProducer, } from "../../build/esm/api/read.js"; +import { runWatcher } from "../../build/esm/api/watcher.js"; import { writeProducer } from "../../build/esm/api/write.js"; import { getDefaultContext } from "../../build/esm/reactivity/context.js"; import { recompute } from "../../build/esm/reactivity/engine/compute.js"; @@ -17,7 +19,7 @@ import { UNINITIALIZED } from "../../build/esm/reactivity/shape/ReactiveNode.js" import ReactiveNode from "../../build/esm/reactivity/shape/ReactiveNode.js"; import { linkEdge } from "../../build/esm/reactivity/shape/methods/connect.js"; import { propagate } from "../../build/esm/reactivity/walkers/propagate.js"; -import { shouldRecompute } from "../../build/esm/reactivity/walkers/shouldRecompute.js"; +import { shouldRecompute } from "../../build/esm/reactivity/walkers/recompute.js"; const runtime = getDefaultContext(); @@ -526,6 +528,40 @@ function warm(fn, iterations) { return sink; } +function nowNs() { + return Number(hrtime.bigint()); +} + +function maybeGc() { + if (globalThis.gc) globalThis.gc(); +} + +function quantile(values, ratio) { + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(sorted.length * ratio) - 1), + ); + return sorted[index]; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[(sorted.length / 2) | 0]; +} + +function formatNs(ns) { + if (ns >= 1e6) return `${(ns / 1e6).toFixed(3)} ms`; + if (ns >= 1e3) return `${(ns / 1e3).toFixed(3)} us`; + return `${ns.toFixed(1)} ns`; +} + +function formatOpsSec(opsSec) { + if (opsSec >= 1e6) return `${(opsSec / 1e6).toFixed(2)} Mops/s`; + if (opsSec >= 1e3) return `${(opsSec / 1e3).toFixed(2)} Kops/s`; + return `${opsSec.toFixed(1)} ops/s`; +} + function bench(label, fn, iterations, warmup = iterations) { warm(fn, warmup); @@ -545,6 +581,118 @@ function bench(label, fn, iterations, warmup = iterations) { console.log(`${label}: ${nsPerOp.toFixed(1)} ns/op | sink=${sink}`); } +function benchTailLatency( + label, + fn, + iterations, + warmup = iterations >> 1, + sampleIterations = 256, + rounds = 7, +) { + warm(fn, warmup); + + const opsSamples = []; + const p50Samples = []; + const p95Samples = []; + const p99Samples = []; + const maxSamples = []; + let sink = 0; + + for (let round = 0; round < rounds; round += 1) { + maybeGc(); + const bulkStart = nowNs(); + + for (let i = 0; i < iterations; i += 1) { + sink ^= fn(i + round * iterations) & 1; + } + + const bulkNs = nowNs() - bulkStart; + opsSamples.push((iterations * 1e9) / bulkNs); + + const latencies = []; + for (let i = 0; i < sampleIterations; i += 1) { + const start = nowNs(); + sink ^= fn(i + round * sampleIterations + 0x100000) & 1; + latencies.push(nowNs() - start); + } + + p50Samples.push(quantile(latencies, 0.5)); + p95Samples.push(quantile(latencies, 0.95)); + p99Samples.push(quantile(latencies, 0.99)); + maxSamples.push(Math.max(...latencies)); + } + + console.log( + `${label}: ops/sec=${formatOpsSec(median(opsSamples))} | p50=${formatNs( + median(p50Samples), + )} | p95=${formatNs(median(p95Samples))} | p99=${formatNs( + median(p99Samples), + )} | max=${formatNs(median(maxSamples))} | sink=${sink}`, + ); +} + +function benchTailLatencyWithHooks({ + label, + prepare, + run, + cleanup, + iterations, + warmup = iterations >> 1, + sampleIterations = 256, + rounds = 7, +}) { + for (let i = 0; i < warmup; i += 1) { + prepare?.(i); + run(i); + cleanup?.(i); + } + + const opsSamples = []; + const p50Samples = []; + const p95Samples = []; + const p99Samples = []; + const maxSamples = []; + let sink = 0; + + for (let round = 0; round < rounds; round += 1) { + maybeGc(); + const bulkStart = nowNs(); + + for (let i = 0; i < iterations; i += 1) { + const index = i + round * iterations; + prepare?.(index); + sink ^= run(index) & 1; + cleanup?.(index); + } + + const bulkNs = nowNs() - bulkStart; + opsSamples.push((iterations * 1e9) / bulkNs); + + const latencies = []; + for (let i = 0; i < sampleIterations; i += 1) { + const index = i + round * sampleIterations + 0x100000; + prepare?.(index); + const start = nowNs(); + sink ^= run(index) & 1; + latencies.push(nowNs() - start); + cleanup?.(index); + } + + p50Samples.push(quantile(latencies, 0.5)); + p95Samples.push(quantile(latencies, 0.95)); + p99Samples.push(quantile(latencies, 0.99)); + maxSamples.push(Math.max(...latencies)); + } + + console.log( + `${label}: ops/sec=${formatOpsSec(median(opsSamples))} | p50=${formatNs( + median(p50Samples), + )} | p95=${formatNs(median(p95Samples))} | p99=${formatNs( + median(p99Samples), + )} | max=${formatNs(median(maxSamples))} | sink=${sink}`, + ); +} + function runBenchSuite() { const chain = buildPropagateChain(64); const fanout = buildPropagateFanout(32, 8); @@ -580,6 +728,147 @@ function runEngineBenchSuite() { bench("api_writeProducer_fanout", () => writeFanout.run(), 100000, 50000); } +function buildWriteProducerWideFanout(width) { + resetRuntime(); + + const source = createProducer(0); + const nodes = []; + + for (let i = 0; i < width; i += 1) { + const leaf = createConsumer(() => i); + nodes.push(leaf); + linkEdge(source, leaf); + } + + let value = 0; + + return { + run() { + value += 1; + writeProducer(source, value); + clearWalkerState(nodes); + return nodes.length; + }, + }; +} + +function buildSharedFanoutWatchers(width) { + let invalidations = 0; + resetRuntime({ + onEffectInvalidated() { + invalidations += 1; + }, + }); + + const source = createProducer(0); + const shared = createConsumer(() => readProducer(source) * 2); + const watchers = []; + + for (let i = 0; i < width; i += 1) { + const watcher = new ReactiveNode( + null, + () => { + readConsumer(shared); + }, + ReactiveNodeState.Watcher | ReactiveNodeState.Changed, + ); + watchers.push(watcher); + runWatcher(watcher); + } + + let value = 0; + + return { + run() { + value += 1; + const before = invalidations; + writeProducer(source, value); + + for (let i = 0; i < watchers.length; i += 1) { + runWatcher(watchers[i]); + } + + return (invalidations - before) ^ (shared.payload & 1); + }, + }; +} + +function buildRunWatcherSharedFanout(width) { + let invalidations = 0; + resetRuntime({ + onEffectInvalidated() { + invalidations += 1; + }, + }); + + const source = createProducer(0); + const shared = createConsumer(() => readProducer(source) * 2); + const watchers = []; + + for (let i = 0; i < width; i += 1) { + const watcher = new ReactiveNode( + null, + () => { + readConsumer(shared); + }, + ReactiveNodeState.Watcher | ReactiveNodeState.Changed, + ); + watchers.push(watcher); + runWatcher(watcher); + } + + const first = watchers[0]; + let value = 0; + let beforeInvalidations = 0; + + return { + prepare() { + value += 1; + beforeInvalidations = invalidations; + writeProducer(source, value); + }, + run() { + runWatcher(first); + return (invalidations - beforeInvalidations) ^ (shared.payload & 1); + }, + cleanup() { + for (let i = 1; i < watchers.length; i += 1) { + runWatcher(watchers[i]); + } + }, + }; +} + +function runTailBenchSuite() { + const wideFanout = buildWriteProducerWideFanout(4096); + const sharedWatchers = buildSharedFanoutWatchers(256); + const sharedWatcherRun = buildRunWatcherSharedFanout(256); + + benchTailLatency( + "p99_writeProducer_wide_fanout_4096", + () => wideFanout.run(), + 6000, + 2000, + 384, + ); + benchTailLatency( + "p99_shared_fanout_watchers_256", + () => sharedWatchers.run(), + 3000, + 1000, + 256, + ); + benchTailLatencyWithHooks({ + label: "p99_runWatcher_shared_propagateOnce_256", + prepare: () => sharedWatcherRun.prepare(), + run: () => sharedWatcherRun.run(), + cleanup: () => sharedWatcherRun.cleanup(), + iterations: 3000, + warmup: 1000, + sampleIterations: 256, + }); +} + function runSingleScenario(name) { switch (name) { case "tracked_prefix_stress_true": { @@ -647,6 +936,29 @@ function runSingleScenario(name) { bench(name, () => scenario.run(), 100000, 50000); return; } + case "p99_writeProducer_wide_fanout_4096": { + const scenario = buildWriteProducerWideFanout(4096); + benchTailLatency(name, () => scenario.run(), 6000, 2000, 384); + return; + } + case "p99_shared_fanout_watchers_256": { + const scenario = buildSharedFanoutWatchers(256); + benchTailLatency(name, () => scenario.run(), 3000, 1000, 256); + return; + } + case "p99_runWatcher_shared_propagateOnce_256": { + const scenario = buildRunWatcherSharedFanout(256); + benchTailLatencyWithHooks({ + label: name, + prepare: () => scenario.prepare(), + run: () => scenario.run(), + cleanup: () => scenario.cleanup(), + iterations: 3000, + warmup: 1000, + sampleIterations: 256, + }); + return; + } default: throw new Error(`Unknown scenario: ${name}`); } @@ -665,6 +977,11 @@ function main() { return; } + if (mode === "p99") { + runTailBenchSuite(); + return; + } + if (mode === "scenario") { const name = process.argv[3]; if (!name) throw new Error("scenario name is required"); diff --git a/packages/@reflex/runtime/tests/runtime.connect.test.ts b/packages/@reflex/runtime/tests/runtime.connect.test.ts index a4377aa..c059f8a 100644 --- a/packages/@reflex/runtime/tests/runtime.connect.test.ts +++ b/packages/@reflex/runtime/tests/runtime.connect.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; -import { ReactiveNodeState, ReactiveNode } from "../src"; +import { ReactiveNodeState, ReactiveNode, createExecutionContext } from "../src"; import { linkEdge, ReactiveEdge, + trackReadActive, unlinkEdge, reuseIncomingEdgeFromSuffixOrCreate, } from "../src/reactivity"; @@ -68,4 +69,49 @@ describe("Reactive graph - edge wiring", () => { expect(bb.prevIn).toBe(cb); expect(target.lastIn).toBe(bb); }); + + it("routes fallback edge reuse through the execution-context seam", () => { + const a = createNode(ReactiveNodeState.Producer); + const b = createNode(ReactiveNodeState.Producer); + const c = createNode(ReactiveNodeState.Producer); + const target = createNode(ReactiveNodeState.Consumer); + const calls: Array<{ + source: ReactiveNode; + consumer: ReactiveNode; + prev: ReactiveEdge | null; + nextExpected: ReactiveEdge | null; + }> = []; + + const ab = linkEdge(a, target); + const bb = linkEdge(b, target); + const cb = linkEdge(c, target); + const context = createExecutionContext( + {}, + { + trackReadFallback(source, consumer, prev, nextExpected) { + calls.push({ source, consumer, prev, nextExpected }); + return reuseIncomingEdgeFromSuffixOrCreate( + source, + consumer, + prev, + nextExpected, + ); + }, + }, + ); + + target.depsTail = ab; + trackReadActive(c, target, context); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + source: c, + consumer: target, + prev: ab, + nextExpected: bb, + }); + expect(target.depsTail).toBe(cb); + expect(ab.nextIn).toBe(cb); + expect(cb.prevIn).toBe(ab); + }); }); diff --git a/packages/@reflex/runtime/tests/runtime.security.test.ts b/packages/@reflex/runtime/tests/runtime.security.test.ts index d654ede..7249089 100644 --- a/packages/@reflex/runtime/tests/runtime.security.test.ts +++ b/packages/@reflex/runtime/tests/runtime.security.test.ts @@ -23,9 +23,8 @@ describe("Reactive runtime - security regressions", () => { context.maybeNotifySettled(); expect(settled).toHaveBeenCalledTimes(1); - expect(Object.getPrototypeOf(context.hooks)).toBe(Object.prototype); - expect("polluted" in context.hooks).toBe(false); - expect((context.hooks as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(context)).toBe(Object.prototype); + expect("polluted" in context).toBe(false); }); it("setHooks ignores inherited callbacks on replacement objects", () => { @@ -43,7 +42,7 @@ describe("Reactive runtime - security regressions", () => { expect(previous).not.toHaveBeenCalled(); expect(inherited).not.toHaveBeenCalled(); - expect(context.hooks.onReactiveSettled).toBe(undefined); + expect(context.onReactiveSettled).toBe(undefined); }); it("keeps direct hook assignments synchronized with cached callbacks", () => { @@ -51,15 +50,15 @@ describe("Reactive runtime - security regressions", () => { const second = vi.fn(); const context = createExecutionContext(); - context.hooks.onReactiveSettled = first; + context.onReactiveSettled = first; context.maybeNotifySettled(); - context.hooks.onReactiveSettled = second; + context.onReactiveSettled = second; context.maybeNotifySettled(); - context.hooks.onReactiveSettled = undefined; + context.onReactiveSettled = undefined; context.maybeNotifySettled(); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); - expect(context.hooks.onReactiveSettled).toBe(undefined); + expect(context.onReactiveSettled).toBe(undefined); }); }); diff --git a/packages/@reflex/runtime/tests/runtime.subtle.test.ts b/packages/@reflex/runtime/tests/runtime.subtle.test.ts index b00e0e5..d92c1a3 100644 --- a/packages/@reflex/runtime/tests/runtime.subtle.test.ts +++ b/packages/@reflex/runtime/tests/runtime.subtle.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { subtle } from "../src"; +import { subtle } from "../src/debug"; import { createProducer, resetRuntime } from "./runtime.test_utils"; describe("Reactive runtime - subtle debug surface", () => { diff --git a/packages/@reflex/runtime/tests/runtime.walkers.test.ts b/packages/@reflex/runtime/tests/runtime.walkers.test.ts index 8479c81..707e4f5 100644 --- a/packages/@reflex/runtime/tests/runtime.walkers.test.ts +++ b/packages/@reflex/runtime/tests/runtime.walkers.test.ts @@ -11,7 +11,7 @@ import { } from "../src"; import { getDefaultContext, - IMMEDIATE, + PROMOTE_CHANGED, propagate, propagateOnce, shouldRecompute, @@ -49,7 +49,7 @@ describe("Reactive runtime - walker invariants", () => { setDefaultContext(createTestContext()); - propagate(source.firstOut!, IMMEDIATE); + propagate(source.firstOut!, PROMOTE_CHANGED); expect(left.state).toBe( ReactiveNodeState.Consumer | ReactiveNodeState.Changed, @@ -65,6 +65,31 @@ describe("Reactive runtime - walker invariants", () => { ); }); + it("propagate keeps sibling continuation promote while child descent resets to Invalid", () => { + const source = createNode(ReactiveNodeState.Producer); + const left = createNode(ReactiveNodeState.Consumer); + const right = createNode(ReactiveNodeState.Consumer); + const leftLeaf = createNode(ReactiveNodeState.Consumer); + + linkEdge(source, left); + linkEdge(source, right); + linkEdge(left, leftLeaf); + + setDefaultContext(createTestContext()); + + propagate(source.firstOut!, PROMOTE_CHANGED); + + expect(left.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Changed, + ); + expect(leftLeaf.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Invalid, + ); + expect(right.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Changed, + ); + }); + it("can mark the whole reachable graph Changed when every subscriber is direct", () => { const source = createNode(ReactiveNodeState.Producer); const left = createNode(ReactiveNodeState.Consumer); @@ -83,7 +108,7 @@ describe("Reactive runtime - walker invariants", () => { linkEdge(source, right); linkEdge(source, watcher); - propagate(source.firstOut!, IMMEDIATE); + propagate(source.firstOut!, PROMOTE_CHANGED); expect(left.state).toBe( ReactiveNodeState.Consumer | ReactiveNodeState.Changed, @@ -110,7 +135,7 @@ describe("Reactive runtime - walker invariants", () => { linkEdge(source, sibling); linkEdge(disposed, disposedLeaf); - propagate(source.firstOut!, IMMEDIATE); + propagate(source.firstOut!, PROMOTE_CHANGED); expect(disposed.state).toBe( ReactiveNodeState.Consumer | ReactiveNodeState.Disposed, @@ -135,7 +160,7 @@ describe("Reactive runtime - walker invariants", () => { linkEdge(source, sibling); tracked.depsTail = prefixEdge; - propagate(source.firstOut!, IMMEDIATE); + propagate(source.firstOut!, PROMOTE_CHANGED); expect(tracked.state).toBe( ReactiveNodeState.Consumer | ReactiveNodeState.Tracking, @@ -166,7 +191,7 @@ describe("Reactive runtime - walker invariants", () => { }, }); - expect(() => propagate(source.firstOut!, IMMEDIATE)).not.toThrow(); + expect(() => propagate(source.firstOut!, PROMOTE_CHANGED)).not.toThrow(); expect(tracked.state).toBe( ReactiveNodeState.Consumer | ReactiveNodeState.Tracking | @@ -189,7 +214,30 @@ describe("Reactive runtime - walker invariants", () => { linkEdge(source, middle); linkEdge(middle, leaf); - propagate(source.firstOut!, IMMEDIATE); + propagate(source.firstOut!, PROMOTE_CHANGED); + + expect(middle.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Changed, + ); + expect(leaf.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Invalid, + ); + }); + + it("clears stale Visited on fast-path subscribers while preserving Changed and Invalid", () => { + const source = createNode(ReactiveNodeState.Producer); + const middle = createNode( + ReactiveNodeState.Consumer | ReactiveNodeState.Visited, + ); + const leaf = createNode( + ReactiveNodeState.Consumer | ReactiveNodeState.Visited, + ); + setDefaultContext(createTestContext()); + + linkEdge(source, middle); + linkEdge(middle, leaf); + + propagate(source.firstOut!, PROMOTE_CHANGED); expect(middle.state).toBe( ReactiveNodeState.Consumer | ReactiveNodeState.Changed, @@ -237,6 +285,33 @@ describe("Reactive runtime - walker invariants", () => { expect(invalidated).toEqual(["watcher"]); }); + it("propagateOnce preserves Visited while upgrading Invalid watchers to Changed", () => { + const source = createNode(ReactiveNodeState.Producer); + const watcher = createNode( + ReactiveNodeState.Watcher | + ReactiveNodeState.Invalid | + ReactiveNodeState.Visited, + ); + const invalidated: ReactiveNode[] = []; + const context = createTestContext({ + onEffectInvalidated(node) { + invalidated.push(node); + }, + }); + setDefaultContext(context); + + linkEdge(source, watcher); + + propagateOnce(source); + + expect(watcher.state).toBe( + ReactiveNodeState.Watcher | + ReactiveNodeState.Changed | + ReactiveNodeState.Visited, + ); + expect(invalidated).toEqual([watcher]); + }); + it("invalidates every watcher that hangs off a shared computed branch", () => { const invalidated: ReactiveNode[] = []; @@ -346,6 +421,78 @@ describe("Reactive runtime - walker invariants", () => { expect(right.state & ReactiveNodeState.Invalid).toBeFalsy(); }); + it("shouldRecompute does not promote sibling invalid subscribers when a shared dependency recomputes same-as-current", () => { + const source = createProducer(1); + const sharedSpy = vi.fn(() => { + readProducer(source); + return 10; + }); + const shared = createConsumer(sharedSpy); + const left = createConsumer(() => readConsumer(shared) + 1); + const right = createConsumer(() => readConsumer(shared) + 2); + + expect(readConsumer(left)).toBe(11); + expect(readConsumer(right)).toBe(12); + + writeProducer(source, 2); + + expect(shared.state & ReactiveNodeState.Changed).toBeTruthy(); + expect(left.state & ReactiveNodeState.Invalid).toBeTruthy(); + expect(right.state & ReactiveNodeState.Invalid).toBeTruthy(); + expect(shouldRecompute(left)).toBe(false); + expect(sharedSpy).toHaveBeenCalledTimes(2); + expect(right.state & ReactiveNodeState.Changed).toBeFalsy(); + expect(right.state & ReactiveNodeState.Invalid).toBeTruthy(); + }); + + it("shouldRecompute scans later branching siblings when the first dependency is already clean", () => { + const leftSource = createProducer(1); + const rightSource = createProducer(10); + const leftSpy = vi.fn(() => readProducer(leftSource) + 1); + const rightSpy = vi.fn(() => readProducer(rightSource) + 1); + const left = createConsumer(leftSpy); + const right = createConsumer(rightSpy); + const root = createConsumer(() => readConsumer(left) + readConsumer(right)); + + expect(readConsumer(root)).toBe(13); + expect(leftSpy).toHaveBeenCalledTimes(1); + expect(rightSpy).toHaveBeenCalledTimes(1); + + writeProducer(rightSource, 20); + + expect(root.state & ReactiveNodeState.Invalid).toBeTruthy(); + expect(left.state & DIRTY_STATE).toBe(0); + expect(right.state & ReactiveNodeState.Changed).toBeTruthy(); + expect(shouldRecompute(root)).toBe(true); + expect(leftSpy).toHaveBeenCalledTimes(1); + expect(rightSpy).toHaveBeenCalledTimes(2); + }); + + it("shouldRecompute clears Invalid when only a later branching sibling recomputes same-as-current", () => { + const leftSource = createProducer(1); + const rightSource = createProducer(10); + const leftSpy = vi.fn(() => readProducer(leftSource) + 1); + const rightSpy = vi.fn(() => { + readProducer(rightSource); + return 20; + }); + const left = createConsumer(leftSpy); + const right = createConsumer(rightSpy); + const root = createConsumer(() => readConsumer(left) + readConsumer(right)); + + expect(readConsumer(root)).toBe(22); + expect(leftSpy).toHaveBeenCalledTimes(1); + expect(rightSpy).toHaveBeenCalledTimes(1); + + writeProducer(rightSource, 99); + + expect(root.state & ReactiveNodeState.Invalid).toBeTruthy(); + expect(shouldRecompute(root)).toBe(false); + expect(root.state & ReactiveNodeState.Invalid).toBeFalsy(); + expect(leftSpy).toHaveBeenCalledTimes(1); + expect(rightSpy).toHaveBeenCalledTimes(2); + }); + it("shouldRecompute routes pull-phase invalidations through the caller context and back to default", () => { const invalidatedA: ReactiveNode[] = []; const invalidatedB: ReactiveNode[] = []; diff --git a/packages/@reflex/runtime/tests/runtime.walkers_reggression.dev.test.ts b/packages/@reflex/runtime/tests/runtime.walkers_reggression.dev.test.ts index 26bbcc4..794747b 100644 --- a/packages/@reflex/runtime/tests/runtime.walkers_reggression.dev.test.ts +++ b/packages/@reflex/runtime/tests/runtime.walkers_reggression.dev.test.ts @@ -6,7 +6,7 @@ import { subtle, type RuntimeDebugEvent, writeProducer, -} from "../src"; +} from "../src/debug"; import { createConsumer, createProducer, diff --git a/packages/@reflex/runtime/vite.config.ts b/packages/@reflex/runtime/vite.config.ts index 2a32211..b3ab539 100644 --- a/packages/@reflex/runtime/vite.config.ts +++ b/packages/@reflex/runtime/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ define: { - __DEV__: true, + __DEV__: false, __TEST__: true, __PROD__: false, }, diff --git a/packages/reflex/README.md b/packages/reflex/README.md index 2387062..cb2740a 100644 --- a/packages/reflex/README.md +++ b/packages/reflex/README.md @@ -18,6 +18,7 @@ It gives you: - a compact signal-style API - runtime-backed execution with explicit effect flushing +- disposable typed models with batched untracked actions - event sources plus composition helpers like `map()`, `filter()`, `merge()`, `scan()`, and `hold()` - predictable semantics for lazy derived values and scheduled effects @@ -75,6 +76,7 @@ The top-level primitives are not methods on `rt`, but they are still runtime-bac - Preserve explicit runtime control instead of hiding scheduling. - Make derived state cheap to read through lazy cached computeds. - Support both state-style and event-style reactive flows. +- Provide a small ownership boundary for feature-local reactive state. - Expose low-level escape hatches only when needed, without forcing them into normal usage. ## Core Primitives @@ -131,6 +133,91 @@ disposeLatest(); - the first item is the accessor you read from - the second item is a disposer that unsubscribes from the event source and releases the internal node +### Models + +```ts +import { + computed, + createModel, + createRuntime, + own, + signal, +} from "@volynets/reflex"; + +createRuntime(); + +const createCounterModel = createModel((ctx, initial = 0) => { + const [count, setCount] = signal(initial); + const doubled = computed(() => count() * 2); + + const timer = own(ctx, { + [Symbol.dispose]() { + console.log("timer disposed"); + }, + }); + + return { + count, + doubled, + inc: ctx.action(() => setCount((value) => value + 1)), + reset: ctx.action(() => setCount(initial)), + timer, + }; +}); + +const counter = createCounterModel(1); + +counter.inc(); +console.log(counter.doubled()); // 4 + +counter[Symbol.dispose](); +``` + +Model rules: + +- return only readable reactive values, `ctx.action(...)`, and nested objects +- model actions run untracked and inside the active `batch()` +- use `ctx.onDispose(...)` or `own(ctx, value)` for owned resources +- do not return `effect()` from a model; effects are rejected by both types and runtime validation +- in dev builds, `own()` warns if the resource looks already disposed + +Full contract: `docs/models.md` + +#### Model Semantics (Contract) + +1. Lifecycle + - created when you call the factory returned by `createModel(...)` + - disposed when `model[Symbol.dispose]()` is called + - disposal is idempotent: calling `model[Symbol.dispose]()` multiple times has no additional effect + - a disposed model is "dead": actions throw, and cleanup hooks will not run again + - dead models are not reusable; construct a new instance instead +2. Ownership contract + - `own(ctx, value)` registers one disposal; sharing the same resource across multiple models will dispose it multiple times + - passing an already-disposed resource is allowed but discouraged; `own()` does not guard against it + - dispose order is guaranteed LIFO (last registered cleanup runs first) + - nested models can be owned: `own(ctx, createChildModel())` is valid +3. Action semantics + - actions can be nested; each action participates in the active `batch()` + - if no batch is active, the outermost action creates one; nested actions reuse it and do not flush independently + - actions run untracked; dependency tracking is suspended during the action + - if an action throws, the error is rethrown and tracking/batch state is restored + - return values pass through unchanged + - actions are synchronous for reactive correctness; async work runs outside the batch/untracked scope +4. Post-dispose behavior + - actions always throw after disposal + - during disposal, the model is already marked dead; actions invoked from cleanups throw the same as after disposal + - reads from previously returned accessors are outside the model contract: they may appear to work, but are not guaranteed to be valid or stable + - effects are not allowed in models; subscriptions or external resources must be torn down via `ctx.onDispose()`/`own()` +5. Visibility + - anything returned from the model is part of its public API + - `own(ctx, value)` and `ctx.onDispose(...)` are lifecycle primitives, not public surface + - keep internal details private by not returning them, or document them explicitly + +Error policy for dispose: + +- all cleanups run in LIFO order +- cleanup errors are logged and do not prevent remaining cleanups from running + ### Event composition ```ts @@ -165,6 +252,7 @@ subscribeOnce(labels, (value) => { - `effect(fn)` runs once immediately on creation. - If an effect returns cleanup, that cleanup runs before the next effect run and on dispose. - With the default runtime, invalidated effects run on `rt.flush()`. +- With `createRuntime({ effectStrategy: "sab" })`, invalidated effects stay lazy during a batch and auto-deliver when the outermost batch exits. - With `createRuntime({ effectStrategy: "eager" })`, invalidated effects flush automatically. - Pure signal and computed reads do not require `flush()`. - Same-value signal writes do not force recomputation. @@ -178,7 +266,7 @@ subscribeOnce(labels, (value) => { ```ts const rt = createRuntime({ - effectStrategy: "flush", // or "eager" + effectStrategy: "flush", // or "sab" / "eager" hooks: { onEffectInvalidated(node) { // low-level integration hook @@ -189,7 +277,7 @@ const rt = createRuntime({ Options: -- `effectStrategy: "flush" | "eager"` controls whether invalidated effects wait for `rt.flush()` or run automatically +- `effectStrategy: "flush" | "sab" | "eager"` controls whether invalidated effects wait for `rt.flush()`, stabilize after `batch()`, or run automatically - `hooks.onEffectInvalidated(node)` is a low-level hook for integrations that want to observe effect invalidation Returned API: @@ -256,6 +344,43 @@ const stop = effect(() => { - cleanup runs before the next execution and on dispose - returns a callable disposer with `.dispose()` +### `createModel(factory)` + +Creates a typed disposable model factory. + +```ts +const createTodoModel = createModel((ctx) => { + const [title, setTitle] = signal(""); + + return { + title, + rename: ctx.action((next: string) => setTitle(next)), + }; +}); +``` + +- returned model instances are disposable via `model[Symbol.dispose]()` +- `ctx.action(...)` creates the only supported function values inside the model shape +- actions are batched and untracked +- nested objects are allowed +- `effect()` values are forbidden inside the returned model shape + +### `own(ctx, value)` + +Registers a nested disposable so it is disposed together with the model. + +```ts +const socket = own(ctx, { + [Symbol.dispose]() { + ws.close(); + }, +}); +``` + +### `isModel(value)` + +Returns `true` when `value` is a Reflex model created by `createModel()`. + ### `rt.event()` Creates an event source. @@ -333,7 +458,7 @@ They are exported as top-level functions, but they run against the currently con ### Do I always need to call `flush()`? -No. You need `flush()` for scheduled effects when using the default `effectStrategy: "flush"`. You do not need `flush()` just to read up-to-date `signal()` or `computed()` values. +No. You need `flush()` for scheduled effects when using the default `effectStrategy: "flush"`. In `effectStrategy: "sab"`, effects auto-deliver after the outermost `batch()`. You do not need `flush()` just to read up-to-date `signal()` or `computed()` values. ### Is `computed()` lazy or eager? @@ -345,7 +470,7 @@ Lazy. It does not run until the first read. After that it behaves like a cached ### Does `effect()` run immediately? -Yes. It runs once on creation. Future re-runs happen after invalidation, either on `rt.flush()` or automatically when using the eager effect strategy. +Yes. It runs once on creation. Future re-runs happen after invalidation, either on `rt.flush()`, at the end of an outermost batch in `sab`, or automatically when using the eager effect strategy. ### Why do `scan()` and `hold()` return tuples instead of only an accessor? diff --git a/packages/reflex/bench/reflex.bench.ts b/packages/reflex/bench/reflex.bench.ts index 469d8f3..bc5fe03 100644 --- a/packages/reflex/bench/reflex.bench.ts +++ b/packages/reflex/bench/reflex.bench.ts @@ -9,7 +9,7 @@ import { import { createRuntime, effect, memo, signal } from "../dist/esm"; -const rt = createRuntime({ effectStrategy: "flush" }); +const rt = createRuntime({ effectStrategy: "sab" }); class ReflexHarness implements BenchHarness { readonly metrics = new HarnessMetrics(); diff --git a/packages/reflex/bench/tail-index.bench.ts b/packages/reflex/bench/tail-index.bench.ts new file mode 100644 index 0000000..f30a4f9 --- /dev/null +++ b/packages/reflex/bench/tail-index.bench.ts @@ -0,0 +1,181 @@ +import { bench, describe } from "vitest"; +import { DIRTY_STATE, ReactiveNodeState } from "@reflex/runtime"; +import { blackhole } from "./shared"; + +const CAPACITIES = [16, 32, 64, 256, 1024] as const; +const INNER_ITERATIONS = 2_000_000; + +const SCHEDULED_DISPOSED = + ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled; + +describe("tail update microbench: pure loop", () => { + for (const capacity of CAPACITIES) { + const mask = capacity - 1; + + bench(`mod capacity=${capacity}`, () => { + let tail = 0; + for (let i = 0; i < INNER_ITERATIONS; ++i) { + tail = (tail + 1) % capacity; + } + blackhole(tail); + }); + + bench(`mask capacity=${capacity}`, () => { + let tail = 0; + for (let i = 0; i < INNER_ITERATIONS; ++i) { + tail = (tail + 1) & mask; + } + blackhole(tail); + }); + + bench(`branch capacity=${capacity}`, () => { + let tail = 0; + for (let i = 0; i < INNER_ITERATIONS; ++i) { + tail += 1; + if (tail === capacity) tail = 0; + } + blackhole(tail); + }); + } +}); + +describe("tail update microbench: scheduler path", () => { + for (const capacity of CAPACITIES) { + const mask = capacity - 1; + + bench(`mod capacity=${capacity}`, () => { + const queue = new Array< + { state: number } | undefined + >(capacity); + const nodes = Array.from({ length: capacity }, () => ({ + state: DIRTY_STATE, + })); + + let head = 0; + let tail = 0; + let size = 0; + + for (let i = 0; i < INNER_ITERATIONS; ++i) { + const node = nodes[i & mask]!; + const state = node.state; + if ((state & SCHEDULED_DISPOSED) !== 0) continue; + + node.state = state | ReactiveNodeState.Scheduled; + queue[tail] = node; + tail = (tail + 1) % capacity; + size += 1; + + if (size === capacity) { + while (size !== 0) { + const next = queue[head]!; + queue[head] = undefined; + head = (head + 1) % capacity; + size -= 1; + next.state &= ~ReactiveNodeState.Scheduled; + } + } + } + + while (size !== 0) { + const next = queue[head]!; + queue[head] = undefined; + head = (head + 1) % capacity; + size -= 1; + next.state &= ~ReactiveNodeState.Scheduled; + } + + blackhole(head + tail + size); + }); + + bench(`mask capacity=${capacity}`, () => { + const queue = new Array< + { state: number } | undefined + >(capacity); + const nodes = Array.from({ length: capacity }, () => ({ + state: DIRTY_STATE, + })); + + let head = 0; + let tail = 0; + let size = 0; + + for (let i = 0; i < INNER_ITERATIONS; ++i) { + const node = nodes[i & mask]!; + const state = node.state; + if ((state & SCHEDULED_DISPOSED) !== 0) continue; + + node.state = state | ReactiveNodeState.Scheduled; + queue[tail] = node; + tail = (tail + 1) & mask; + size += 1; + + if (size === capacity) { + while (size !== 0) { + const next = queue[head]!; + queue[head] = undefined; + head = (head + 1) & mask; + size -= 1; + next.state &= ~ReactiveNodeState.Scheduled; + } + } + } + + while (size !== 0) { + const next = queue[head]!; + queue[head] = undefined; + head = (head + 1) & mask; + size -= 1; + next.state &= ~ReactiveNodeState.Scheduled; + } + + blackhole(head + tail + size); + }); + + bench(`branch capacity=${capacity}`, () => { + const queue = new Array< + { state: number } | undefined + >(capacity); + const nodes = Array.from({ length: capacity }, () => ({ + state: DIRTY_STATE, + })); + + let head = 0; + let tail = 0; + let size = 0; + + for (let i = 0; i < INNER_ITERATIONS; ++i) { + const node = nodes[i & mask]!; + const state = node.state; + if ((state & SCHEDULED_DISPOSED) !== 0) continue; + + node.state = state | ReactiveNodeState.Scheduled; + queue[tail] = node; + tail += 1; + if (tail === capacity) tail = 0; + size += 1; + + if (size === capacity) { + while (size !== 0) { + const next = queue[head]!; + queue[head] = undefined; + head += 1; + if (head === capacity) head = 0; + size -= 1; + next.state &= ~ReactiveNodeState.Scheduled; + } + } + } + + while (size !== 0) { + const next = queue[head]!; + queue[head] = undefined; + head += 1; + if (head === capacity) head = 0; + size -= 1; + next.state &= ~ReactiveNodeState.Scheduled; + } + + blackhole(head + tail + size); + }); + } +}); diff --git a/packages/reflex/package.json b/packages/reflex/package.json index 64a4adf..f7538d7 100644 --- a/packages/reflex/package.json +++ b/packages/reflex/package.json @@ -1,6 +1,6 @@ { "name": "@volynets/reflex", - "version": "0.1.1", + "version": "0.2.0", "type": "module", "description": "Public Reflex facade with a connected runtime", "license": "MIT", @@ -51,6 +51,7 @@ "bench": "vitest bench", "dev": "node ../../node_modules/typescript/bin/tsc -w -p tsconfig.build.json", "typecheck": "node ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.typecheck.json", + "typecheck:tests": "node ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.type-tests.json", "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm build:clean" }, "devDependencies": { diff --git a/packages/reflex/src/api/derived.ts b/packages/reflex/src/api/derived.ts index 4ec690e..c354b92 100644 --- a/packages/reflex/src/api/derived.ts +++ b/packages/reflex/src/api/derived.ts @@ -1,5 +1,6 @@ import { readConsumerEager, readConsumerLazy } from "@reflex/runtime"; import { createComputedNode } from "../infra"; +import { markModelReadable } from "../infra/modelValue"; /** * Creates a lazy derived accessor. @@ -41,9 +42,9 @@ import { createComputedNode } from "../infra"; * @see memo * @see effect */ -export function computed(fn: () => T): Accessor { +export function computed(fn: () => T): Computed { const node = createComputedNode(fn); - return readConsumerLazy.bind(null, node) as Accessor; + return markModelReadable(readConsumerLazy.bind(null, node) as Computed); } /** @@ -83,8 +84,8 @@ export function computed(fn: () => T): Accessor { * * @see computed */ -export function memo(fn: () => T): Accessor { +export function memo(fn: () => T): Memo { const node = createComputedNode(fn); - readConsumerEager.call(null, node); - return readConsumerLazy.bind(null, node) as Accessor; + readConsumerEager(node); + return markModelReadable(readConsumerLazy.bind(null, node) as Memo); } diff --git a/packages/reflex/src/api/effect.ts b/packages/reflex/src/api/effect.ts index 39d72e7..f6aec03 100644 --- a/packages/reflex/src/api/effect.ts +++ b/packages/reflex/src/api/effect.ts @@ -98,6 +98,10 @@ export function withEffectCleanupRegistrar( * - The first run happens synchronously during `effect()` creation. * - With the default runtime strategy, later re-runs are queued until * `rt.flush()`. + * - With `createRuntime({ effectStrategy: "ranked" })`, later re-runs are + * queued until `rt.flush()` and then drained in descending watcher rank. + * - With `createRuntime({ effectStrategy: "sab" })`, invalidations stay lazy + * during propagation but auto-deliver after the outermost `rt.batch()`. * - With `createRuntime({ effectStrategy: "eager" })`, invalidations flush * automatically. * - Reads performed inside cleanup do not become dependencies of the next run. @@ -108,11 +112,11 @@ export function withEffectCleanupRegistrar( * @see memo */ export function effect(fn: EffectFn): Destructor { - const node = createWatcherNode(fn); const context = getDefaultContext(); + const node = createWatcherNode(fn); runWatcher(node); - const dispose = () => disposeWatcher(node); + const dispose = disposeWatcher.bind(null, node) as Destructor; context.registerWatcherCleanup(dispose); return dispose; } diff --git a/packages/reflex/src/api/event.ts b/packages/reflex/src/api/event.ts index 9487480..f0cb692 100644 --- a/packages/reflex/src/api/event.ts +++ b/packages/reflex/src/api/event.ts @@ -234,14 +234,15 @@ function createScan( reducer: (acc: A, value: T) => A, ): [read: Accessor, dispose: Destructor] { const node = createAccumulator(seed); - const accessor = () => - isDisposedNode(node) ? (node.payload as A) : readProducer(node); + let current = seed; + const accessor = () => (isDisposedNode(node) ? current : readProducer(node)); let unsubscribe: Destructor | undefined = source.subscribe((value: T) => { /* c8 ignore start -- disposal unsubscribes before a queued delivery can reach this callback */ if (isDisposedNode(node)) return; /* c8 ignore stop */ - writeProducer(node, reducer(node.payload, value)); + current = reducer(current, value); + writeProducer(node, current); }); function dispose(): void { diff --git a/packages/reflex/src/api/signal.ts b/packages/reflex/src/api/signal.ts index 3058075..10f9a4f 100644 --- a/packages/reflex/src/api/signal.ts +++ b/packages/reflex/src/api/signal.ts @@ -1,5 +1,6 @@ import { readProducer, writeProducer } from "@reflex/runtime"; import { createSignalNode } from "../infra"; +import { markModelReadable } from "../infra/modelValue"; /** * Creates writable reactive state. @@ -49,21 +50,20 @@ import { createSignalNode } from "../infra"; * @see memo * @see effect */ -export function signal(initialValue: T): readonly [Accessor, Setter] { +export function signal(initialValue: T): readonly [Signal, Setter] { const node = createSignalNode(initialValue); - function set(input: SetInput): T { + function set(input: SetInput) { + const payload = node.payload; const next = typeof input === "function" - ? (input as (prev: T) => T)(node.payload as T) + ? (input as (prev: T) => T)(payload as T) : input; - writeProducer.call(null, node, next); - - return next; + writeProducer(node, next); } return [ - readProducer.bind(null, node) as Accessor, + markModelReadable(readProducer.bind(null, node) as Signal), set as Setter, ] as const; } diff --git a/packages/reflex/src/globals.d.ts b/packages/reflex/src/globals.d.ts index 7028b4b..33d2a31 100644 --- a/packages/reflex/src/globals.d.ts +++ b/packages/reflex/src/globals.d.ts @@ -1,5 +1,9 @@ declare const __DEV__: boolean; +interface SymbolConstructor { + readonly dispose: unique symbol; +} + /** * Cleanup function returned from an effect. */ diff --git a/packages/reflex/src/index.ts b/packages/reflex/src/index.ts index 508797f..4377041 100644 --- a/packages/reflex/src/index.ts +++ b/packages/reflex/src/index.ts @@ -3,4 +3,12 @@ export { signal, computed, memo, effect, withEffectCleanupRegistrar } from "./api"; export { subscribeOnce, map, filter, merge, scan, hold } from "./api"; -export { createRuntime } from "./infra"; +export { batch, createRuntime } from "./infra"; +export { createModel, isModel, own } from "./infra/model"; +export type { + Model, + ModelFactory, + ModelShape, + ModelTuple, + ValidatedModelShape, +} from "./infra/model"; diff --git a/packages/reflex/src/infra/factory.ts b/packages/reflex/src/infra/factory.ts index b504716..b2e0201 100644 --- a/packages/reflex/src/infra/factory.ts +++ b/packages/reflex/src/infra/factory.ts @@ -9,26 +9,38 @@ import { EventSource as RuntimeEventSource } from "./event"; export const UNINITIALIZED = Symbol("UNINITIALIZED") as unknown; -export function createSignalNode(payload: T) { - return new RuntimeReactiveNode(payload, null, PRODUCER_INITIAL_STATE); -} +export const createSignalNode = (payload: T) => { + return new RuntimeReactiveNode( + payload, + /*TODO: replace with undefined*/ null, + PRODUCER_INITIAL_STATE, + ); +}; -export function createSource(): RuntimeEventSource { +export const createSource = (): RuntimeEventSource => { return new RuntimeEventSource(); -} +}; -export function createResourceStateNode() { - return new RuntimeReactiveNode(0, null, PRODUCER_INITIAL_STATE); -} +export const createResourceStateNode = () => { + return new RuntimeReactiveNode( + 0, + /*TODO: replace with undefined*/ null, + PRODUCER_INITIAL_STATE, + ); +}; -export function createAccumulator(payload: T): ReactiveNode { - return new RuntimeReactiveNode(payload, null, PRODUCER_INITIAL_STATE); -} +export const createAccumulator = (payload: T): ReactiveNode => { + return new RuntimeReactiveNode( + payload, + /*TODO: replace with undefined*/ null, + PRODUCER_INITIAL_STATE, + ); +}; -export function createComputedNode(fn: () => T) { - return new RuntimeReactiveNode(UNINITIALIZED as T, fn, CONSUMER_INITIAL_STATE); -} +export const createComputedNode = (fn: () => T) => { + return new RuntimeReactiveNode(undefined as T, fn, CONSUMER_INITIAL_STATE); +}; -export function createWatcherNode(compute: EffectFn): ReactiveNode { - return new RuntimeReactiveNode(null, compute, WATCHER_INITIAL_STATE); -} +export const createWatcherNode = (compute: EffectFn): ReactiveNode => { + return new RuntimeReactiveNode(undefined, compute, WATCHER_INITIAL_STATE); +}; diff --git a/packages/reflex/src/infra/index.ts b/packages/reflex/src/infra/index.ts index 5ca8860..fe092a0 100644 --- a/packages/reflex/src/infra/index.ts +++ b/packages/reflex/src/infra/index.ts @@ -1,2 +1,3 @@ export * from "./factory"; +export * from "./model"; export * from "./runtime"; diff --git a/packages/reflex/src/infra/model.ts b/packages/reflex/src/infra/model.ts new file mode 100644 index 0000000..992f9a4 --- /dev/null +++ b/packages/reflex/src/infra/model.ts @@ -0,0 +1,284 @@ +import { getDefaultContext } from "@reflex/runtime"; +import { batch } from "./runtime"; +import { + isModelActionValue, + isModelReadableValue, + markModelAction, + type ModelAction, + type ModelActionBrand, +} from "./modelValue"; + +type Cleanup = () => void; +type ModelState = { disposed: boolean }; + +const DISPOSE = Symbol.dispose; +const MODEL_DISPOSED = Symbol("MODEL_DISPOSED"); + +interface DisposableLike { + [DISPOSE](): void; +} + +interface ModelContext { + /** + * Wraps a model mutation so it runs untracked and inside the active batch. + * + * Model actions are the only supported function values inside a model shape. + */ + action( + fn: (...args: TArgs) => TReturn, + ): ModelAction; + + /** + * Registers cleanup that runs when the model is disposed. + */ + onDispose(fn: Cleanup): void; + + /** + * Indicates whether the current model instance has been disposed. + */ + readonly disposed: boolean; +} + +const MODEL_BRAND = Symbol("MODEL_BRAND"); + +type ModelTypeError = Message; + +type DisposedHint = { + disposed?: boolean; + isDisposed?: boolean; + [MODEL_DISPOSED]?: boolean; +}; + +type ReadableModelBrand = + | Brand<"signal"> + | Brand<"computed"> + | Brand<"memo"> + | Brand<"derived"> + | Brand<"realtime"> + | Brand<"stream">; + +type PrimitiveModelValue = + | ReadableModelBrand + | ModelActionBrand + | DisposableLike; + +type InvalidModelValue = + ModelTypeError<"Model values must be readable reactive values, model actions, or nested objects.">; + +type InvalidEffectValue = + ModelTypeError<"Effects are not allowed inside models. Use computed values, actions, and ctx.onDispose() instead.">; + +type ValidateModel = + T extends Effect + ? InvalidEffectValue + : T extends PrimitiveModelValue + ? T + : T extends (...args: unknown[]) => unknown + ? InvalidModelValue + : T extends object + ? { [K in keyof T]: ValidateModel } + : InvalidModelValue; + +export type Model = ValidateModel & DisposableLike; +export type ModelShape = T & ValidateModel; +export type ValidatedModelShape = ValidateModel; + +/** + * Factory used by `createModel()`. + * + * The return value may contain only: + * - readable reactive values such as `signal()`, `computed()`, and `memo()` + * - actions created with `ctx.action(...)` + * - nested plain objects following the same rules + */ +export type ModelFactory = ( + ctx: ModelContext, + ...args: TArgs +) => TModel & ValidateModel; + +export type ModelTuple = ( + ...args: TArgs +) => Model; + +type ModelFactoryArgs = TFactory extends ( + ctx: ModelContext, + ...args: infer TArgs +) => unknown + ? TArgs + : never; + +type ModelFactoryReturn = TFactory extends ( + ...args: never[] +) => infer TModel + ? TModel + : never; + +type CheckedModelFactory> = + TFactory & + (( + ctx: ModelContext, + ...args: ModelFactoryArgs + ) => ValidateModel>); + +function createAction( + state: ModelState, + fn: (...args: TArgs) => TReturn, +): ModelAction { + return markModelAction(function modelAction( + this: unknown, + ...args: TArgs + ): TReturn { + if (state.disposed) { + throw new Error( + "Cannot call a model action after the model was disposed.", + ); + } + + return batch(() => { + const context = getDefaultContext(); + const prev = context.activeComputed; + context.activeComputed = null; + + try { + return fn.apply(this, args); + } finally { + context.activeComputed = prev; + } + }); + }); +} + +function validateModelShape(value: unknown, path = "model"): void { + if ( + isModelReadableValue(value) || + isModelActionValue(value) || + isModel(value) + ) { + return; + } + + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError( + `Invalid ${path}: model values must be readable reactive values, model actions, or nested objects.`, + ); + } + + for (const [key, nested] of Object.entries(value)) { + validateModelShape(nested, `${path}.${key}`); + } +} + +export function isModel(value: unknown): value is DisposableLike { + return typeof value === "object" && value !== null && MODEL_BRAND in value; +} + +/** + * Registers a nested disposable so it is disposed with the parent model. + */ +export function own(ctx: ModelContext, value: T): T { + if (typeof __DEV__ !== "undefined" && __DEV__) { + const kind = typeof value; + if (kind === "object" || kind === "function") { + const hint = value as DisposedHint; + if (hint[MODEL_DISPOSED] === true || hint.disposed === true || hint.isDisposed === true) { + console.warn( + "own(ctx, value) received a disposed resource. This is allowed but likely a bug.", + ); + } + } + } + + ctx.onDispose(() => value[DISPOSE]!()); + return value; +} + +/** + * Creates a disposable model factory with strict model-shape validation. + * + * Models are intended for grouping reactive accessors, actions, and owned + * resources behind one lifecycle boundary. + * + * Actions created with `ctx.action(...)`: + * - run untracked + * - run inside the active `batch()` + * - throw after model disposal + * + * Effects are intentionally forbidden inside model shapes. If a model needs + * ownership-aware resources, create them outside the returned object and wire + * their teardown through `ctx.onDispose()` or `own(ctx, value)`. + * + * Disposal is idempotent and marks the model as dead before running cleanups. + * Cleanup errors are logged and do not prevent remaining cleanups from running. + */ +export function createModel>( + factory: CheckedModelFactory, +): ModelTuple, ModelFactoryArgs> { + return ( + ...args: ModelFactoryArgs + ): Model> => { + const state: ModelState = { disposed: false }; + const cleanups: Cleanup[] = []; + + const ctx: ModelContext = { + action(fn) { + return createAction(state, fn); + }, + + onDispose(fn) { + if (state.disposed) { + throw new Error( + "Cannot register cleanup after the model was disposed.", + ); + } + + cleanups.push(fn); + }, + + get disposed() { + return state.disposed; + }, + }; + + const model = factory(ctx, ...args) as Model>; + validateModelShape(model); + + Object.defineProperty(model, MODEL_BRAND, { + value: true, + enumerable: false, + configurable: false, + writable: false, + }); + Object.defineProperty(model, MODEL_DISPOSED, { + value: false, + enumerable: false, + configurable: false, + writable: true, + }); + + Object.defineProperty(model, DISPOSE, { + value() { + if (state.disposed) return; + state.disposed = true; + (model as DisposedHint)[MODEL_DISPOSED] = true; + + for (let i = cleanups.length - 1; i >= 0; i--) { + const cleanup = cleanups[i]; + if (!cleanup) continue; + + try { + cleanup(); + } catch (error) { + console.error("Error during model disposal:", error); + } + } + + cleanups.length = 0; + }, + enumerable: false, + configurable: false, + writable: false, + }); + + return model; + }; +} diff --git a/packages/reflex/src/infra/modelValue.ts b/packages/reflex/src/infra/modelValue.ts new file mode 100644 index 0000000..b24b56f --- /dev/null +++ b/packages/reflex/src/infra/modelValue.ts @@ -0,0 +1,49 @@ +export const MODEL_ACTION = Symbol("MODEL_ACTION"); +export const MODEL_READABLE = Symbol("MODEL_READABLE"); + +export type ModelActionBrand = { + readonly [MODEL_ACTION]: true; +}; + +export type ModelAction = + ((...args: TArgs) => TReturn) & ModelActionBrand; + +export function markModelAction( + fn: (...args: TArgs) => TReturn, +): ModelAction { + Object.defineProperty(fn, MODEL_ACTION, { + value: true, + enumerable: false, + configurable: false, + writable: false, + }); + + return fn as ModelAction; +} + +export function markModelReadable>(fn: T): T { + Object.defineProperty(fn, MODEL_READABLE, { + value: true, + enumerable: false, + configurable: false, + writable: false, + }); + + return fn; +} + +export function isModelActionValue( + value: unknown, +): value is ModelAction { + return ( + typeof value === "function" && + (value as { [MODEL_ACTION]?: true })[MODEL_ACTION] === true + ); +} + +export function isModelReadableValue(value: unknown): value is Accessor { + return ( + typeof value === "function" && + (value as { [MODEL_READABLE]?: true })[MODEL_READABLE] === true + ); +} diff --git a/packages/reflex/src/infra/runtime.ts b/packages/reflex/src/infra/runtime.ts index b685e57..1c76511 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -1,47 +1,34 @@ import { createExecutionContext, setDefaultContext } from "@reflex/runtime"; import type { ExecutionContext, EngineHooks } from "@reflex/runtime"; -import type { EffectStrategy } from "../policy"; +import { subscribeEvent } from "./event"; +import { createSource } from "./factory"; +import { EventDispatcher } from "../policy"; +import type { EffectStrategy } from "../policy/scheduler"; import { createEffectScheduler, - EffectSchedulerMode, - EventDispatcher, resolveEffectSchedulerMode, -} from "../policy"; -import { subscribeEvent } from "./event"; -import { createSource } from "./factory"; +} from "../policy/scheduler"; -interface RuntimeOptions { - /** - * Optional low-level runtime hooks forwarded to the execution context. - * - * These hooks are composed with Reflex's scheduler integration rather than - * replacing it. - */ +type BatchFn = (fn: () => T) => T; + +let activeBatch: BatchFn = (fn) => fn(); + +export interface RuntimeOptions { hooks?: EngineHooks; - /** - * Controls when invalidated effects are executed. - * - * - `"flush"` queues reruns until `rt.flush()` is called. - * - `"eager"` flushes reruns automatically. - */ effectStrategy?: EffectStrategy; } function createRuntimeInfrastructure(options?: RuntimeOptions) { const executionContext = createExecutionContext(options?.hooks); - const schedulerMode = resolveEffectSchedulerMode(options?.effectStrategy); - const scheduler = createEffectScheduler( - schedulerMode, + resolveEffectSchedulerMode(options?.effectStrategy), executionContext, ); - const dispatcher = new EventDispatcher((fn) => scheduler.batch(fn)); + const dispatcher = new EventDispatcher(scheduler.batch); executionContext.setRuntimeHooks( scheduler.enqueue, - schedulerMode === EffectSchedulerMode.Eager - ? scheduler.notifySettled - : undefined, + scheduler.runtimeNotifySettled, ); executionContext.resetState(); @@ -54,128 +41,34 @@ function createRuntimeInfrastructure(options?: RuntimeOptions) { }; } -/** - * Push-based event stream that allows observers to subscribe to future values. - * - * `Event` is the read-only view of an event source. It does not expose - * mutation, only observation. - * - * @typeParam T - Event payload type. - */ +export function batch(fn: () => T): T { + return activeBatch(fn); +} + export interface Event { - /** - * Registers a callback for future event deliveries. - * - * @param fn - Callback invoked for each emitted value. - * - * @returns Destructor that unsubscribes `fn`. - */ subscribe(fn: (value: T) => void): Destructor; } -/** - * Mutable event source created by `Runtime.event()`. - * - * @typeParam T - Event payload type. - */ export interface EventSource extends Event { - /** - * Emits a value to current subscribers using the runtime dispatcher. - * - * Nested emits are queued after the current delivery completes, preserving - * FIFO ordering across sources created by the same runtime. - * - * @param value - Event payload to deliver. - */ emit(value: T): void; } -/** - * Connected Reflex runtime returned by `createRuntime()`. - * - * The runtime owns the event dispatcher, effect scheduler, and execution - * context used by the top-level Reflex primitives. - */ export interface Runtime { batch(fn: () => T): T; - /** - * Creates a new mutable event source associated with this runtime. - * - * @typeParam T - Event payload type. - * - * @returns Event source with `emit(value)` and `subscribe(fn)`. - */ event(): EventSource; - /** - * Flushes queued effect re-runs immediately. - * - * In the default `"flush"` strategy, call this after writes when you want - * scheduled effects to observe the latest stable snapshot. - */ flush(): void; - /** - * Underlying execution context used by this runtime. - * - * Most application code does not need this. It exists for low-level - * integrations, tests, and diagnostics. - */ readonly ctx: ExecutionContext; } -/** - * Creates and installs the active Reflex runtime. - * - * `createRuntime` wires together an execution context, effect scheduler, and - * event dispatcher, then makes that context the default runtime used by the - * top-level Reflex primitives exported from this package. - * - * @param options - Optional runtime configuration: - * - `effectStrategy` controls whether invalidated effects flush on - * `rt.flush()` or automatically. - * - `hooks` installs low-level runtime hooks that are composed with Reflex's - * scheduler integration. - * - * @returns Connected runtime with event creation, flushing, and context - * access. - * - * @example - * ```ts - * const rt = createRuntime(); - * const ticks = rt.event(); - * const [count, setCount] = signal(0); - * - * ticks.subscribe((value) => { - * setCount((current) => current + value); - * }); - * - * effect(() => { - * console.log(count()); - * }); - * - * ticks.emit(1); - * rt.flush(); - * ``` - * - * @remarks - * - Call this once during app startup or per test case to establish the active - * runtime. - * - Creating a new runtime replaces the previously active default context. - * - `rt.flush()` is primarily for scheduled effects; signals and computed - * reads stay current without it. - * - Event sources created by `rt.event()` share one dispatcher and preserve - * FIFO delivery order. - */ export function createRuntime(options?: RuntimeOptions): Runtime { const { scheduler, dispatcher, executionContext } = createRuntimeInfrastructure(options); - return { - get ctx() { - return executionContext; - }, - batch(fn) { - return scheduler.batch(fn); - }, + activeBatch = scheduler.batch; + + return { + ctx: executionContext, + batch: scheduler.batch, event() { const source = createSource(); @@ -189,9 +82,6 @@ export function createRuntime(options?: RuntimeOptions): Runtime { }, }; }, - - flush() { - scheduler.flush(); - }, + flush: scheduler.flush, }; } diff --git a/packages/reflex/src/policy/SCHEDULER_SEMANTICS.md b/packages/reflex/src/policy/SCHEDULER_SEMANTICS.md new file mode 100644 index 0000000..0d6cb7d --- /dev/null +++ b/packages/reflex/src/policy/SCHEDULER_SEMANTICS.md @@ -0,0 +1,389 @@ +# Effect Scheduler Semantics + +This document defines the observable semantics of Reflex effect scheduling and +the test invariants that should hold for each policy: + +- `flush` +- `sab` +- `eager` + +It is intentionally written from the perspective of externally observable +behavior rather than implementation details. The goal is to make benchmarks, +tests, and adapter integrations agree on what is "correct" for each mode. + +## Terms + +- `signal`: mutable reactive source +- `computed`: pull-based derived value +- `effect`: push-style observer scheduled for re-execution +- `batch`: transaction boundary that groups writes +- `flush`: explicit delivery of queued effects +- "stable": no more queued effect work remains for the current snapshot +- "scheduled": an effect is queued to run but has not run yet + +## Shared Guarantees + +These guarantees hold in every mode: + +1. `signal` writes performed inside `batch()` become the committed source of +truth when the batch exits. +2. `computed` reads after the batch must observe the latest committed signal +state, even if effects have not been delivered yet. +3. `effect` re-execution is deduplicated per scheduled node. +4. Effects must not flush in the middle of propagation. +5. Nested batches behave like one outer transaction for delivery purposes. +6. Disposed effects must not run again. +7. FIFO delivery order is preserved for the scheduler queue. + +The main semantic difference between modes is not whether values are correct, +but when queued effects become observable. + +## Mode: `flush` + +### Intent + +`flush` is the low-overhead mode. It separates mutation/propagation from +effect delivery. + +### Observable Contract + +After `batch(fn)` in `flush` mode: + +- signal state is up to date +- computed reads are up to date +- effects may still be pending +- the system is not guaranteed to be stable + +This means the following is correct: + +```ts +rt.batch(() => { + setSource(3); +}); + +expect(source()).toBe(3); +expect(derived()).toBe(6); +expect(effectSpy).toHaveBeenCalledTimes(1); + +rt.flush(); + +expect(effectSpy).toHaveBeenCalledTimes(2); +``` + +### Correct Test Invariants + +Tests in `flush` mode may assert immediately after `batch()`: + +- signal values changed +- computed values changed +- effects are still at their previous call count + +Tests in `flush` mode may assert after `flush()`: + +- queued effects have run +- cleanup has executed if applicable +- the system is stable for the current snapshot + +### Incorrect Expectation + +This is not a valid invariant for strict `flush`: + +```ts +rt.batch(() => { + setSource(3); +}); + +expect(effectSpy).toHaveBeenCalledTimes(2); +``` + +That expectation asks for stable-after-batch semantics, which `flush` does not +promise. + +## Mode: `sab` + +### Intent + +`sab` means "stable after batch". + +It keeps lazy enqueue semantics during propagation, but auto-delivers pending +effects when the outermost batch exits and the runtime is in a safe idle state. + +### Observable Contract + +Inside a batch: + +- effects stay queued +- no mid-propagation flush occurs + +After the outermost batch exits: + +- signal state is current +- computed reads are current +- queued effects are auto-delivered +- the system is stable for the current snapshot + +This means the following is correct: + +```ts +rt.batch(() => { + setSource(3); +}); + +expect(source()).toBe(3); +expect(derived()).toBe(6); +expect(effectSpy).toHaveBeenCalledTimes(2); +``` + +### Correct Test Invariants + +Tests in `sab` mode should assert: + +- no effect rerun occurs inside the batch body +- effect reruns are visible immediately after the outermost batch exits +- reads inside the batch can still observe current pull-based values + +### Important Nuance + +If the outermost batch exits while propagation is still active or a computed is +currently evaluating, `sab` does not flush yet. The queue stays pending until a +later explicit `flush()`. The contract is "stable after batch when safe", not +"flush under every circumstance". + +## Mode: `eager` + +### Intent + +`eager` auto-delivers effects whenever it is safe to do so. + +### Observable Contract + +When the runtime is idle and not inside propagation: + +- enqueue can trigger immediate delivery +- exiting the outermost batch stabilizes the system automatically +- explicit `flush()` is normally unnecessary + +This means the following is correct: + +```ts +rt.batch(() => { + setSource(3); +}); + +expect(source()).toBe(3); +expect(derived()).toBe(6); +expect(effectSpy).toHaveBeenCalledTimes(2); +``` + +### Correct Test Invariants + +Tests in `eager` mode may assert immediately after: + +- a plain write performed while idle +- an outermost `batch()` exit +- event delivery completion + +that: + +- signal values are current +- computed values are current +- effects have already observed the latest stable snapshot + +### Important Distinction From `sab` + +`eager` is more aggressive than `sab`: + +- `eager` may flush on idle enqueue outside batches +- `sab` does not change enqueue into an auto-flushing operation +- `sab` only changes what happens at outermost batch exit + +### Important Nuance + +`eager` still must not flush during active propagation or while an enclosing +computed is running. Delivery happens at the earliest safe point, not literally +"immediately no matter what". + +## Test Matrix + +The same scenario should be asserted differently depending on mode. + +### Scenario A: Write inside batch, then read signal and computed + +```ts +rt.batch(() => { + setSource(3); +}); +``` + +Valid in all modes: + +```ts +expect(source()).toBe(3); +expect(derived()).toBe(6); +``` + +### Scenario B: Write inside batch, then inspect effect call count + +Initial effect call count: `1` + +Valid expectations: + +- `flush`: still `1` until explicit `flush()` +- `sab`: already `2` after batch exit +- `eager`: already `2` after batch exit + +### Scenario C: Multiple writes in one batch + +```ts +rt.batch(() => { + setLeft(2); + setRight(20); +}); +``` + +Valid in all modes: + +- effects must observe one consistent final snapshot +- intermediate partial snapshots must not leak through effect delivery + +Expected timing: + +- `flush`: after explicit `rt.flush()` +- `sab`: after batch exit +- `eager`: after batch exit + +### Scenario D: Nested batch + +```ts +rt.batch(() => { + setA(1); + rt.batch(() => { + setB(2); + }); +}); +``` + +Valid expectations: + +- no post-batch delivery after the inner batch alone +- delivery happens only when the outermost batch exits +- in `flush`, even outermost batch exit still does not deliver without + explicit `flush()` + +### Scenario E: Write outside batch while idle + +```ts +setSource(3); +``` + +Expected timing: + +- `flush`: effect remains queued until `flush()` +- `sab`: effect remains queued until `flush()` +- `eager`: effect may run automatically + +This is the most important behavioral difference between `eager` and `sab`. + +## Benchmark Interpretation + +These modes are expected to have different costs: + +- `flush` without `flush()` is cheaper because it postpones effect work +- `batch(); flush();` is more expensive because it actually delivers effects +- `sab` is close in cost to `batch(); flush();` +- `eager` can be cheaper or more expensive depending on workload shape, but it + still pays for auto-delivery + +Therefore, this comparison is not apples-to-apples: + +- `flush` without delivery +- `flush` with delivery + +If a benchmark expects the effect to have already re-run, then it is measuring +delivery cost, not only propagation cost. + +## Adapter Guidance + +Adapters should decide explicitly which contract they expose. + +### Strict `flush` Adapter + +Use this when you want cheap writes and explicit stabilization: + +```ts +withBatch(fn) { + return rt.batch(fn); +} + +settleEffects() { + rt.flush(); +} +``` + +### Stable-After-Batch Adapter + +Use this when tests or integrations require post-batch stability: + +```ts +withBatch(fn) { + return rt.batch(fn); +} + +// runtime created with effectStrategy: "sab" +``` + +or: + +```ts +withBatch(fn) { + const result = rt.batch(fn); + rt.flush(); + return result; +} +``` + +### Eager Adapter + +Use this when the integration wants auto-delivery semantics generally: + +```ts +createRuntime({ effectStrategy: "eager" }); +``` + +## Recommended Test Strategy + +Do not force one invariant onto all scheduler modes. + +Instead, split tests into: + +1. Pull correctness tests + - signal state after writes + - computed consistency after writes + +2. Delivery timing tests + - whether effects are still queued + - when effects become observable + +3. Stabilization tests + - whether the system is guaranteed settled after a boundary + +In practice: + +- if a test asserts `effectSpy === 2` immediately after `batch()`, it is a + test for `sab` or `eager`, not strict `flush` +- if a test asserts `signal` and `computed` only, it is valid across all modes +- if a benchmark wants the cheapest `flush` path, it must not demand settled + effects as part of correctness + +## Summary + +- `flush` is correct when effects remain pending after `batch()` +- `sab` is correct when effects stay lazy during the batch but are delivered at + outermost batch exit +- `eager` is correct when effects are auto-delivered at the earliest safe point + +The key rule is: + +Correctness is mode-relative. + +The runtime should not be judged by an invariant it never promised to uphold. diff --git a/packages/reflex/src/policy/effect_scheduler.ts b/packages/reflex/src/policy/effect_scheduler.ts deleted file mode 100644 index e2a53a6..0000000 --- a/packages/reflex/src/policy/effect_scheduler.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - DIRTY_STATE, - ReactiveNodeState, - runWatcher, - getDefaultContext, -} from "@reflex/runtime"; -import type { ExecutionContext, ReactiveNode } from "@reflex/runtime"; -import type { UNINITIALIZED } from "../infra/factory"; - -export const enum EffectSchedulerMode { - Flush = 0, - Eager = 1, -} - -export const enum SchedulerPhase { - Idle = 0, - Batching = 1, - Flushing = 2, -} - -export type EffectStrategy = "flush" | "eager"; - -export function resolveEffectSchedulerMode( - strategy: EffectStrategy | undefined, -): EffectSchedulerMode { - return strategy === "eager" - ? EffectSchedulerMode.Eager - : EffectSchedulerMode.Flush; -} - -export interface EffectScheduler { - readonly queue: ReactiveNode[]; - readonly mode: EffectSchedulerMode; - readonly context: ExecutionContext; - - enqueue(node: ReactiveNode): void; - batch(fn: () => T): T; - flush(): void; - notifySettled(): void; - reset(): void; - - get head(): number; - get batchDepth(): number; - get phase(): SchedulerPhase; -} - -function noopNotifySettled(): void {} - -export function createEffectScheduler( - mode: EffectSchedulerMode = EffectSchedulerMode.Flush, - context?: ExecutionContext, -): EffectScheduler { - const queue: ReactiveNode[] = []; - const eager = mode === EffectSchedulerMode.Eager; - const getContext = context === undefined ? getDefaultContext : () => context; - - let head = 0; - let batchDepth = 0; - let phase = SchedulerPhase.Idle; - - function enqueueFlush(node: ReactiveNode): void { - const state = node.state; - if ( - (state & (ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled)) !== - 0 - ) { - return; - } - - node.state = state | ReactiveNodeState.Scheduled; - queue.push(node); - } - - function enqueueEager(node: ReactiveNode): void { - const state = node.state; - if ( - (state & (ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled)) !== - 0 - ) { - return; - } - - node.state = state | ReactiveNodeState.Scheduled; - queue.push(node); - - const currentContext = getContext(); - if ( - phase === SchedulerPhase.Idle && - batchDepth === 0 && - currentContext.propagationDepth === 0 && - currentContext.activeComputed === null - ) { - flushQueue(); - } - } - - function enterBatch(): void { - if (++batchDepth === 1 && phase !== SchedulerPhase.Flushing) { - phase = SchedulerPhase.Batching; - } - } - - function leaveBatchFlush(): void { - if (--batchDepth !== 0) return; - if (phase === SchedulerPhase.Flushing) return; - - phase = SchedulerPhase.Idle; - } - - function leaveBatchEager(): void { - if (--batchDepth !== 0) return; - if (phase === SchedulerPhase.Flushing) return; - - phase = SchedulerPhase.Idle; - - if (head < queue.length) { - flushQueue(); - } - } - - const enqueue = eager ? enqueueEager : enqueueFlush; - const leaveBatch = eager ? leaveBatchEager : leaveBatchFlush; - - function batch(fn: () => T): T { - enterBatch(); - try { - return fn(); - } finally { - leaveBatch(); - } - } - - function drainQueue(): void { - while (head < queue.length) { - const node = queue[head++]!; - const state = node.state & ~ReactiveNodeState.Scheduled; - node.state = state; - - if ( - (state & ReactiveNodeState.Disposed) === 0 && - (state & DIRTY_STATE) !== 0 - ) { - runWatcher(node); - } - } - } - - function flushQueue(): void { - if (phase === SchedulerPhase.Flushing) return; - if (head >= queue.length) return; - - phase = SchedulerPhase.Flushing; - - try { - drainQueue(); - } finally { - queue.length = 0; - head = 0; - phase = batchDepth > 0 ? SchedulerPhase.Batching : SchedulerPhase.Idle; - } - } - - function notifySettledEager(): void { - const currentContext = getContext(); - if ( - phase === SchedulerPhase.Idle && - batchDepth === 0 && - currentContext.propagationDepth === 0 && - currentContext.activeComputed === null && - head < queue.length - ) { - flushQueue(); - } - } - - const flush = flushQueue; - const notifySettled = eager ? notifySettledEager : noopNotifySettled; - - function reset(): void { - for (let i = head; i < queue.length; ++i) { - queue[i]!.state &= ~ReactiveNodeState.Scheduled; - } - - queue.length = 0; - head = 0; - batchDepth = 0; - phase = SchedulerPhase.Idle; - } - - return { - queue, - mode, - get context() { - return getContext(); - }, - enqueue, - batch, - flush, - notifySettled, - reset, - get head() { - return head; - }, - get batchDepth() { - return batchDepth; - }, - get phase() { - return phase; - }, - }; -} diff --git a/packages/reflex/src/policy/index.ts b/packages/reflex/src/policy/index.ts index 382c4a6..5ecf931 100644 --- a/packages/reflex/src/policy/index.ts +++ b/packages/reflex/src/policy/index.ts @@ -1,2 +1 @@ -export * from "./effect_scheduler"; export * from "./event_dispatcher"; diff --git a/packages/reflex/src/policy/scheduler/index.ts b/packages/reflex/src/policy/scheduler/index.ts new file mode 100644 index 0000000..093915a --- /dev/null +++ b/packages/reflex/src/policy/scheduler/index.ts @@ -0,0 +1,6 @@ +export * from "./variants"; +export * from "./scheduler.constants"; +export * from "./scheduler.core"; +export * from "./scheduler.infra"; +export * from "./scheduler.queue"; +export * from "./scheduler.types"; diff --git a/packages/reflex/src/policy/scheduler/scheduler.constants.ts b/packages/reflex/src/policy/scheduler/scheduler.constants.ts new file mode 100644 index 0000000..d5c4987 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.constants.ts @@ -0,0 +1,18 @@ +import { ReactiveNodeState } from "@reflex/runtime"; + +export const enum EffectSchedulerMode { + Flush = 0, + Eager = 1, + SAB = 2, + Ranked = 3, +} + +export const enum SchedulerPhase { + Idle = 0, + Batching = 1, + Flushing = 2, +} + +export const SCHEDULED_OR_DISPOSED = + ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled; +export const UNSCHEDULE_MASK = ~ReactiveNodeState.Scheduled; diff --git a/packages/reflex/src/policy/scheduler/scheduler.core.ts b/packages/reflex/src/policy/scheduler/scheduler.core.ts new file mode 100644 index 0000000..6827ac8 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.core.ts @@ -0,0 +1,165 @@ +import type { ExecutionContext, ReactiveNode } from "@reflex/runtime"; +import { ReactiveNodeState, runWatcher } from "@reflex/runtime"; +import { createWatcherQueue } from "./scheduler.queue"; +import type { EffectSchedulerMode } from "./scheduler.constants"; +import { + SCHEDULED_OR_DISPOSED, + SchedulerPhase, + UNSCHEDULE_MASK, +} from "./scheduler.constants"; +import type { + SchedulerCore, + SchedulerEnqueue, + SchedulerBatch, + SchedulerNotifySettled, + SchedulerRuntimeNotifySettled, + EffectScheduler, + EffectNode, + WatcherQueue, +} from "./scheduler.types"; + +const runner = runWatcher.bind(null); + +/** + * Marks an effect watcher node as scheduled. + * + * This is a low-level helper used by scheduler integrations and tests to set + * the runtime's scheduled flag on a watcher node. + */ +export function effectScheduled(node: EffectNode) { + node.state |= ReactiveNodeState.Scheduled; +} + +/** + * Clears the scheduled flag from an effect watcher node. + * + * This is a low-level helper used by scheduler integrations and tests to mark + * a watcher as no longer queued for execution. + */ +export function effectUnscheduled(node: EffectNode) { + node.state &= ~ReactiveNodeState.Scheduled; +} + +export function isContextSettled(context: ExecutionContext): boolean { + return context.propagationDepth === 0 && context.activeComputed === null; +} + +export function isRuntimeInactive( + context: ExecutionContext, + core: SchedulerCore, +): boolean { + return ( + core.phase === SchedulerPhase.Idle && + core.batchDepth === 0 && + isContextSettled(context) + ); +} + +export function createSchedulerCore(): SchedulerCore { + const queue = createWatcherQueue(); + let batchDepth = 0; + let phase = SchedulerPhase.Idle; + + function flush(): void { + if (phase === SchedulerPhase.Flushing) return; + if (queue.size === 0) return; + + phase = SchedulerPhase.Flushing; + + try { + while (queue.size !== 0) { + const node = queue.shift()!, + s = node.state; + node.state = s & UNSCHEDULE_MASK; + runner(node); + } + } finally { + queue.clear(); + phase = batchDepth > 0 ? SchedulerPhase.Batching : SchedulerPhase.Idle; + } + } + + return { + queue, + flush, + + enterBatch() { + if (++batchDepth === 1 && phase !== SchedulerPhase.Flushing) { + phase = SchedulerPhase.Batching; + } + }, + + leaveBatch() { + if (--batchDepth !== 0) { + return false; + } + + if (phase === SchedulerPhase.Flushing) { + return false; + } + + phase = SchedulerPhase.Idle; + return true; + }, + + reset() { + while (queue.size !== 0) { + queue.shift()!.state &= UNSCHEDULE_MASK; + } + + queue.clear(); + batchDepth = 0; + phase = SchedulerPhase.Idle; + }, + get batchDepth() { + return batchDepth; + }, + get phase() { + return phase; + }, + }; +} + +export function tryEnqueue(queue: WatcherQueue, node: ReactiveNode): boolean { + const effectNode = node as EffectNode; + const state = effectNode.state; + if ((state & SCHEDULED_OR_DISPOSED) !== 0) { + return false; + } + + effectNode.state = state | ReactiveNodeState.Scheduled; + queue.push(effectNode); + return true; +} + +export function createSchedulerInstance( + mode: EffectSchedulerMode, + context: ExecutionContext, + core: SchedulerCore, + enqueue: SchedulerEnqueue, + batch: SchedulerBatch, + notifySettled: SchedulerNotifySettled, + runtimeNotifySettled: SchedulerRuntimeNotifySettled, +): EffectScheduler { + return { + ring: core.queue.ring, + mode, + context, + runtimeNotifySettled, + enqueue, + batch, + flush: core.flush, + notifySettled, + reset: core.reset, + + get head() { + return core.queue.head; + }, + get batchDepth() { + return core.batchDepth; + }, + get phase() { + return core.phase; + }, + }; +} diff --git a/packages/reflex/src/policy/scheduler/scheduler.infra.ts b/packages/reflex/src/policy/scheduler/scheduler.infra.ts new file mode 100644 index 0000000..105a926 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.infra.ts @@ -0,0 +1,41 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { getDefaultContext } from "@reflex/runtime"; +import { EffectSchedulerMode } from "./scheduler.constants"; +import type { EffectScheduler } from "./scheduler.types"; +import { + createEagerScheduler, + createRankedScheduler, + createSabScheduler, + createFlushScheduler, +} from "./variants"; + +export type EffectStrategy = "flush" | "eager" | "sab" | "ranked"; + +const strategyMap: Record = { + eager: EffectSchedulerMode.Eager, + sab: EffectSchedulerMode.SAB, + flush: EffectSchedulerMode.Flush, + ranked: EffectSchedulerMode.Ranked, +}; + +export function resolveEffectSchedulerMode( + strategy?: EffectStrategy, +): EffectSchedulerMode { + return strategy ? strategyMap[strategy] : EffectSchedulerMode.Flush; +} + +export function createEffectScheduler( + mode: EffectSchedulerMode = EffectSchedulerMode.Flush, + context: ExecutionContext = getDefaultContext(), +): EffectScheduler { + switch (mode) { + case EffectSchedulerMode.Eager: + return createEagerScheduler(context); + case EffectSchedulerMode.Ranked: + return createRankedScheduler(context); + case EffectSchedulerMode.SAB: + return createSabScheduler(context); + default: + return createFlushScheduler(context); + } +} diff --git a/packages/reflex/src/policy/scheduler/scheduler.queue.ts b/packages/reflex/src/policy/scheduler/scheduler.queue.ts new file mode 100644 index 0000000..4eb90cd --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.queue.ts @@ -0,0 +1,74 @@ +import type { WatcherQueue, EffectNode } from "./scheduler.types"; + +const INITIAL_QUEUE_CAPACITY = 16; + +function growWatcherQueue(queue: WatcherQueue): void { + const ring = queue.ring; + const capacity = ring.length; + if (capacity === 0) { + ring.length = INITIAL_QUEUE_CAPACITY; + return; + } + + const size = queue.size; + const head = queue.head; + const mask = capacity - 1; + const nextCapacity = capacity << 1; + const next = new Array(nextCapacity); + + for (let i = 0; i < size; ++i) { + next[i] = ring[(head + i) & mask]!; + } + + ring.length = nextCapacity; + for (let i = 0; i < size; ++i) { + ring[i] = next[i]!; + } + + queue.head = 0; + queue.tail = size; +} + +function pushWatcherQueue(this: WatcherQueue, node: EffectNode): void { + const ring = this.ring; + if (this.size === ring.length) { + growWatcherQueue(this); + } + + const tail = this.tail; + ring[tail] = node; + this.tail = (tail + 1) & (ring.length - 1); + ++this.size; +} + +function shiftWatcherQueue(this: WatcherQueue): EffectNode | null { + if (this.size === 0) { + return null; + } + + const ring = this.ring; + const head = this.head; + const node = ring[head]!; + ring[head] = undefined as never; + this.head = (head + 1) & (ring.length - 1); + --this.size; + return node; +} + +function clearWatcherQueue(this: WatcherQueue): void { + this.head = 0; + this.tail = 0; + this.size = 0; +} + +export function createWatcherQueue(): WatcherQueue { + return { + ring: [], + head: 0, + tail: 0, + size: 0, + push: pushWatcherQueue, + shift: shiftWatcherQueue, + clear: clearWatcherQueue, + }; +} diff --git a/packages/reflex/src/policy/scheduler/scheduler.types.ts b/packages/reflex/src/policy/scheduler/scheduler.types.ts new file mode 100644 index 0000000..22f623c --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.types.ts @@ -0,0 +1,54 @@ +import type { ExecutionContext, ReactiveNode } from "@reflex/runtime"; +import type { + EffectSchedulerMode, + SchedulerPhase, +} from "./scheduler.constants"; + +export type EffectNode = ReactiveNode; + +export interface WatcherQueue { + readonly ring: EffectNode[]; + head: number; + tail: number; + size: number; + + push(node: EffectNode): void; + shift(): EffectNode | null; + clear(): void; +} + +export function noopNotifySettled(): void {} + +export interface SchedulerCore { + readonly queue: WatcherQueue; + flush(): void; + enterBatch(): void; + leaveBatch(): boolean; + reset(): void; + + get batchDepth(): number; + get phase(): SchedulerPhase; +} + +export interface EffectScheduler { + readonly ring: EffectNode[]; + readonly mode: EffectSchedulerMode; + readonly context: ExecutionContext; + readonly runtimeNotifySettled: (() => void) | undefined; + + enqueue(node: ReactiveNode): void; + batch(fn: () => T): T; + flush(): void; + notifySettled(): void; + reset(): void; + + get head(): number; + get batchDepth(): number; + get phase(): SchedulerPhase; +} + +export type SchedulerBatch = EffectScheduler["batch"]; +export type SchedulerEnqueue = EffectScheduler["enqueue"]; +export type SchedulerNotifySettled = EffectScheduler["notifySettled"]; +export type SchedulerRuntimeNotifySettled = + EffectScheduler["runtimeNotifySettled"]; diff --git a/packages/reflex/src/policy/scheduler/variants/index.ts b/packages/reflex/src/policy/scheduler/variants/index.ts new file mode 100644 index 0000000..a4194a8 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/index.ts @@ -0,0 +1,4 @@ +export * from "./scheduler.eager"; +export * from "./scheduler.flush"; +export * from "./scheduler.ranked"; +export * from "./scheduler.sab"; diff --git a/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts b/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts new file mode 100644 index 0000000..664e445 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts @@ -0,0 +1,46 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { EffectSchedulerMode } from "../scheduler.constants"; +import { + createSchedulerCore, + isRuntimeInactive, + createSchedulerInstance, + tryEnqueue, +} from "../scheduler.core"; +import type { EffectNode, EffectScheduler } from "../scheduler.types"; + +export function createEagerScheduler( + context: ExecutionContext, +): EffectScheduler { + const core = createSchedulerCore(); + const queue = core.queue; + const notifySettled = (): void => { + if (isRuntimeInactive(context, core) && queue.size !== 0) { + core.flush(); + } + }; + const enqueueToQueue = tryEnqueue.bind(null, queue); + const enqueue = (node: EffectNode) => { + if (!enqueueToQueue(node)) return; + if (isRuntimeInactive(context, core)) core.flush(); + }; + const batch = (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + if (core.leaveBatch() && queue.size !== 0) { + core.flush(); + } + } + }; + + return createSchedulerInstance( + EffectSchedulerMode.Eager, + context, + core, + enqueue, + batch, + notifySettled, + notifySettled, + ); +} diff --git a/packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts b/packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts new file mode 100644 index 0000000..e465076 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts @@ -0,0 +1,35 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { EffectSchedulerMode } from "../scheduler.constants"; +import { + createSchedulerCore, + createSchedulerInstance, + tryEnqueue, +} from "../scheduler.core"; +import type { EffectScheduler } from "../scheduler.types"; +import { noopNotifySettled } from "../scheduler.types"; + +export function createFlushScheduler( + context: ExecutionContext, +): EffectScheduler { + const core = createSchedulerCore(); + const queue = core.queue; + const enqueue = tryEnqueue.bind(null, queue); + const batch = (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + core.leaveBatch(); + } + }; + + return createSchedulerInstance( + EffectSchedulerMode.Flush, + context, + core, + enqueue, + batch, + noopNotifySettled, + undefined, + ); +} diff --git a/packages/reflex/src/policy/scheduler/variants/scheduler.ranked.ts b/packages/reflex/src/policy/scheduler/variants/scheduler.ranked.ts new file mode 100644 index 0000000..096946c --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.ranked.ts @@ -0,0 +1,118 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { runWatcher } from "@reflex/runtime"; +import { + EffectSchedulerMode, + SchedulerPhase, + UNSCHEDULE_MASK, +} from "../scheduler.constants"; +import { createSchedulerInstance, tryEnqueue } from "../scheduler.core"; +import { createWatcherQueue } from "../scheduler.queue"; +import type { + EffectNode, + EffectScheduler, + SchedulerCore, +} from "../scheduler.types"; +import { noopNotifySettled } from "../scheduler.types"; + +type RankedEffectNode = EffectNode & { + priority?: number; + rank?: number; +}; + +const runner = runWatcher.bind(null); + +function getNodeRank(node: EffectNode): number { + const rankedNode = node as RankedEffectNode; + return rankedNode.priority ?? rankedNode.rank ?? 0; +} + +export function createRankedScheduler( + context: ExecutionContext, +): EffectScheduler { + const queue = createWatcherQueue(); + let batchDepth = 0; + let phase = SchedulerPhase.Idle; + + const flush = (): void => { + if (phase === SchedulerPhase.Flushing) return; + if (queue.size === 0) return; + + phase = SchedulerPhase.Flushing; + const pending: EffectNode[] = []; + + try { + while (queue.size !== 0) { + pending.push(queue.shift()!); + } + + pending.sort((left, right) => getNodeRank(right) - getNodeRank(left)); + + for (let i = 0; i < pending.length; ++i) { + const node = pending[i]!, + s = node.state; + node.state = s & UNSCHEDULE_MASK; + runner(node); + } + } finally { + queue.clear(); + phase = batchDepth > 0 ? SchedulerPhase.Batching : SchedulerPhase.Idle; + } + }; + + const core: SchedulerCore = { + queue, + flush, + enterBatch() { + if (++batchDepth === 1 && phase !== SchedulerPhase.Flushing) { + phase = SchedulerPhase.Batching; + } + }, + leaveBatch() { + if (--batchDepth !== 0) { + return false; + } + + if (phase === SchedulerPhase.Flushing) { + return false; + } + + phase = SchedulerPhase.Idle; + return true; + }, + reset() { + while (queue.size !== 0) { + queue.shift()!.state &= UNSCHEDULE_MASK; + } + + queue.clear(); + batchDepth = 0; + phase = SchedulerPhase.Idle; + }, + get batchDepth() { + return batchDepth; + }, + get phase() { + return phase; + }, + }; + + const enqueue = tryEnqueue.bind(null, queue); + const batch = (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + core.leaveBatch(); + } + }; + + return createSchedulerInstance( + EffectSchedulerMode.Ranked, + context, + core, + enqueue, + batch, + noopNotifySettled, + undefined, + ); +} diff --git a/packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts b/packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts new file mode 100644 index 0000000..98b3e5e --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { EffectSchedulerMode } from "../scheduler.constants"; +import { + createSchedulerCore, + createSchedulerInstance, + isContextSettled, + tryEnqueue, +} from "../scheduler.core"; +import type { EffectScheduler } from "../scheduler.types"; +import { noopNotifySettled } from "../scheduler.types"; + +export function createSabScheduler(context: ExecutionContext): EffectScheduler { + const core = createSchedulerCore(); + const queue = core.queue; + const enqueue = tryEnqueue.bind(null, queue); + + const batch = (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + if (core.leaveBatch() && queue.size !== 0 && isContextSettled(context)) { + core.flush(); + } + } + }; + + return createSchedulerInstance( + EffectSchedulerMode.SAB, + context, + core, + enqueue, + batch, + noopNotifySettled, + undefined, + ); +} diff --git a/packages/reflex/src/unstable/index.ts b/packages/reflex/src/unstable/index.ts index fa0336b..e81a9c1 100644 --- a/packages/reflex/src/unstable/index.ts +++ b/packages/reflex/src/unstable/index.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference /// export * from "./resource"; diff --git a/packages/reflex/tests/reflex.basic.test.ts b/packages/reflex/tests/reflex.basic.test.ts index 19395eb..fd61b56 100644 --- a/packages/reflex/tests/reflex.basic.test.ts +++ b/packages/reflex/tests/reflex.basic.test.ts @@ -33,7 +33,10 @@ describe("Reactive system - basic correctness", () => { it("supports updater functions", () => { const [count, setCount] = signal(2); - expect(setCount((prev) => prev + 3)).toBe(5); + setCount((prev) => prev + 3); + + // new: set count return nothing + // expect(setCount((prev) => prev + 3)).toBe(5); expect(count()).toBe(5); }); diff --git a/packages/reflex/tests/reflex.effects.test.ts b/packages/reflex/tests/reflex.effects.test.ts index c4196aa..9b213b1 100644 --- a/packages/reflex/tests/reflex.effects.test.ts +++ b/packages/reflex/tests/reflex.effects.test.ts @@ -47,6 +47,26 @@ describe("Reactive system - effects", () => { expect(spy).toHaveBeenCalledTimes(2); }); + it("can flush after batch exits in sab mode", () => { + const rt = createRuntime({ + effectStrategy: "sab", + }); + const [source, setSource] = signal(1); + const spy = vi.fn(() => { + source(); + }); + + effect(spy); + expect(spy).toHaveBeenCalledTimes(1); + + rt.batch(() => { + setSource(2); + expect(spy).toHaveBeenCalledTimes(1); + }); + + expect(spy).toHaveBeenCalledTimes(2); + }); + it("reruns after transitive memo invalidation on flush", () => { const rt = createRuntime(); const [source, setSource] = signal(1); @@ -66,6 +86,42 @@ describe("Reactive system - effects", () => { expect(spy).toHaveBeenCalledTimes(2); }); + it("propagates through long linear chains without dropping updates", () => { + const rt = createRuntime(); + const [source, setSource] = signal(73); + const tapValues = new Map(); + + let current = source; + for (let depth = 0; depth < 192; ++depth) { + const prev = current; + current = memo(() => prev() + ((depth & 3) + 1)); + + if (depth === 47 || depth === 95 || depth === 143 || depth === 191) { + const tap = current; + effect(() => { + tapValues.set(depth, tap()); + }); + } + } + + const expectedPrefixSum = (depthInclusive: number): number => { + let total = 0; + for (let i = 0; i <= depthInclusive; ++i) { + total += (i & 3) + 1; + } + return total; + }; + + rt.flush(); + + setSource(75); + rt.flush(); + + for (const depth of [47, 95, 143, 191]) { + expect(tapValues.get(depth)).toBe(75 + expectedPrefixSum(depth)); + } + }); + it("callable scope disposes the effect", () => { const rt = createRuntime(); const [source, setSource] = signal(1); diff --git a/packages/reflex/tests/reflex.exports.test.ts b/packages/reflex/tests/reflex.exports.test.ts index 826caf6..466c8ac 100644 --- a/packages/reflex/tests/reflex.exports.test.ts +++ b/packages/reflex/tests/reflex.exports.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import * as reflex from "../src"; import * as api from "../src/api"; import * as infra from "../src/infra"; -import * as policy from "../src/policy"; +import * as policy from "../src/policy/scheduler"; +import * as d from "../src/policy"; import * as unstable from "../src/unstable"; import { resource } from "../src/unstable/resource"; +import { createModel, isModel, own } from "../src/infra/model"; describe("Reactive system - exports", () => { it("re-exports the public API from the top-level barrel", () => { @@ -18,13 +20,17 @@ describe("Reactive system - exports", () => { expect(reflex.merge).toBe(api.merge); expect(reflex.scan).toBe(api.scan); expect(reflex.hold).toBe(api.hold); + expect(typeof reflex.batch).toBe("function"); + expect(reflex.createModel).toBe(createModel); + expect(reflex.isModel).toBe(isModel); + expect(reflex.own).toBe(own); expect(reflex.createRuntime).toBe(infra.createRuntime); expect("resource" in reflex).toBe(false); }); it("re-exports policy helpers from the policy barrel", () => { expect(typeof policy.createEffectScheduler).toBe("function"); - expect(typeof policy.EventDispatcher).toBe("function"); + expect(typeof d.EventDispatcher).toBe("function"); expect(typeof policy.resolveEffectSchedulerMode).toBe("function"); }); diff --git a/packages/reflex/tests/reflex.factory.test.ts b/packages/reflex/tests/reflex.factory.test.ts index 31204bc..978229c 100644 --- a/packages/reflex/tests/reflex.factory.test.ts +++ b/packages/reflex/tests/reflex.factory.test.ts @@ -32,7 +32,7 @@ describe("Reactive system - factory helpers", () => { expect(node.compute).toBe(compute); expect(node.state).toBe(CONSUMER_INITIAL_STATE); - expect(node.payload).toBe(UNINITIALIZED); + expect(node.payload).toBeUndefined(); }); it("creates effect nodes with watcher state", () => { @@ -42,7 +42,7 @@ describe("Reactive system - factory helpers", () => { expect(node.compute).toBe(compute); expect(node.state).toBe(WATCHER_INITIAL_STATE); expect(node.state & ReactiveNodeState.Watcher).toBeTruthy(); - expect(node.payload).toBeNull(); + expect(node.payload).toBeUndefined(); }); it("creates empty event sources", () => { diff --git a/packages/reflex/tests/reflex.model.test.ts b/packages/reflex/tests/reflex.model.test.ts new file mode 100644 index 0000000..24e6d24 --- /dev/null +++ b/packages/reflex/tests/reflex.model.test.ts @@ -0,0 +1,170 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import { describe, expect, it, vi } from "vitest"; +import { computed } from "../src/api/derived"; +import { effect } from "../src/api/effect"; +import { signal } from "../src/api/signal"; +import { createModel, isModel, own } from "../src/infra/model"; +import { createRuntime } from "./reflex.test_utils"; + +describe("Reactive system - model actions", () => { + it("runs model actions untracked", () => { + const rt = createRuntime(); + const [source, setSource] = signal(1); + const snapshots: number[] = []; + + const createTestModel = createModel((ctx) => ({ + source, + act: ctx.action(() => source()), + retarget: ctx.action((value: number) => setSource(value)), + })); + + const model = createTestModel(); + + effect(() => { + snapshots.push(model.act()); + }); + + expect(snapshots).toEqual([1]); + + model.retarget(2); + rt.flush(); + + expect(snapshots).toEqual([1]); + }); + + it("batches writes performed inside a model action", () => { + const rt = createRuntime({ effectStrategy: "eager" }); + const [count, setCount] = signal(0); + const seen: number[] = []; + + const createCounterModel = createModel((ctx) => ({ + count, + bumpTwice: ctx.action(() => { + setCount(1); + setCount(2); + }), + })); + + const model = createCounterModel(); + + effect(() => { + seen.push(model.count()); + }); + + expect(seen).toEqual([0]); + + model.bumpTwice(); + + expect(seen).toEqual([0, 2]); + expect(model.count()).toBe(2); + rt.flush(); + expect(seen).toEqual([0, 2]); + }); + + it("supports nested readable values and branded actions", () => { + createRuntime(); + const [count, setCount] = signal(1); + const doubled = computed(() => count() * 2); + + const createCounterModel = createModel((ctx) => ({ + count, + nested: { + doubled, + inc: ctx.action(() => setCount((value) => value + 1)), + }, + })); + + const model = createCounterModel(); + + expect(isModel(model)).toBe(true); + expect(model.count()).toBe(1); + expect(model.nested.doubled()).toBe(2); + + model.nested.inc(); + + expect(model.count()).toBe(2); + expect(model.nested.doubled()).toBe(4); + }); + + it("rejects effects returned from a model factory", () => { + createRuntime(); + const [count] = signal(1); + + expect(() => + createModel(() => ({ + stop: effect(() => { + count(); + }), + }))(), + ).toThrowError( + "Invalid model.stop: model values must be readable reactive values, model actions, or nested objects.", + ); + }); + + it("rejects plain functions returned from a model factory", () => { + createRuntime(); + + expect(() => + createModel(() => ({ + invalid: () => 123, + }))(), + ).toThrowError( + "Invalid model.invalid: model values must be readable reactive values, model actions, or nested objects.", + ); + }); + + it("throws when calling an action after disposal", () => { + createRuntime(); + + const createTestModel = createModel((ctx) => ({ + run: ctx.action(() => 1), + })); + + const model = createTestModel(); + + model[Symbol.dispose](); + + expect(() => model.run()).toThrowError( + "Cannot call a model action after the model was disposed.", + ); + }); + + it("runs cleanup functions in reverse order during disposal", () => { + createRuntime(); + const calls: string[] = []; + + const createTestModel = createModel((ctx) => { + ctx.onDispose(() => calls.push("first")); + ctx.onDispose(() => calls.push("second")); + + return { + run: ctx.action(() => {}), + }; + }); + + const model = createTestModel(); + model[Symbol.dispose](); + + expect(calls).toEqual(["second", "first"]); + }); + + it("owns nested disposables through ctx.onDispose", () => { + createRuntime(); + const dispose = vi.fn(); + + const child = { + [Symbol.dispose]: dispose, + }; + + const createTestModel = createModel((ctx) => ({ + child: own(ctx, child), + })); + + const model = createTestModel(); + model[Symbol.dispose](); + + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/reflex/tests/reflex.model.typecheck.ts b/packages/reflex/tests/reflex.model.typecheck.ts new file mode 100644 index 0000000..a3bef52 --- /dev/null +++ b/packages/reflex/tests/reflex.model.typecheck.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import { computed } from "../src/api/derived"; +import { signal } from "../src/api/signal"; +import { + createModel, + type ModelShape, +} from "../src/infra/model"; + +const [count, setCount] = signal(0); +const doubled = computed(() => count() * 2); + +createModel((ctx) => ({ + count, + doubled, + inc: ctx.action(() => setCount((value) => value + 1)), + nested: { + reset: ctx.action(() => setCount(0)), + }, +})); + +createModel((ctx) => ({ + zeroArg: ctx.action(() => count()), +})); + +const validShape: ModelShape<{ + count: Signal; + doubled: Computed; +}> = { + count, + doubled, +}; diff --git a/packages/reflex/tests/reflex.policy.test.ts b/packages/reflex/tests/reflex.policy.test.ts index e515397..e0827a8 100644 --- a/packages/reflex/tests/reflex.policy.test.ts +++ b/packages/reflex/tests/reflex.policy.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it, vi } from "vitest"; +vi.unmock("@reflex/runtime"); import { createWatcherNode } from "../src/infra/factory"; import { createEffectScheduler, EffectSchedulerMode, resolveEffectSchedulerMode, -} from "../src/policy/effect_scheduler"; +} from "../src/policy/scheduler"; import { EventDispatcher } from "../src/policy/event_dispatcher"; -import { - ReactiveNodeState, -} from "@reflex/runtime"; +import { ReactiveNodeState } from "@reflex/runtime"; import type { EventBoundary, EventSubscriber } from "../src/infra/event"; import { appendSubscriber, @@ -35,6 +34,10 @@ describe("Reactive system - policy helpers", () => { EffectSchedulerMode.Flush, ); expect(resolveEffectSchedulerMode("flush")).toBe(EffectSchedulerMode.Flush); + expect(resolveEffectSchedulerMode("ranked")).toBe( + EffectSchedulerMode.Ranked, + ); + expect(resolveEffectSchedulerMode("sab")).toBe(EffectSchedulerMode.SAB); expect(resolveEffectSchedulerMode("eager")).toBe(EffectSchedulerMode.Eager); }); @@ -75,6 +78,19 @@ describe("Reactive system - policy helpers", () => { expect(spy).toHaveBeenCalledTimes(1); }); + it("sab scheduler auto-delivers after batch exits", () => { + const scheduler = createEffectScheduler(EffectSchedulerMode.SAB); + const spy = vi.fn(() => {}); + const node = createWatcherNode(spy); + + scheduler.batch(() => { + scheduler.enqueue(node); + expect(spy).not.toHaveBeenCalled(); + }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + it("ignores disposed effect nodes", () => { const scheduler = createEffectScheduler(EffectSchedulerMode.Flush); const spy = vi.fn(() => {}); @@ -116,20 +132,21 @@ describe("Reactive system - policy helpers", () => { expect(spy).toHaveBeenCalledTimes(1); }); - it("can take the guarded auto-flush branch in finally when forced", () => { - const scheduler = createEffectScheduler(EffectSchedulerMode.Flush) as - createEffectScheduler & { - shouldAutoFlush(): boolean; - }; - const spy = vi.fn(() => {}); - const node = createWatcherNode(spy); - - scheduler.shouldAutoFlush = () => true; - scheduler.enqueue(node); - scheduler.flush(); - - expect(spy).toHaveBeenCalledTimes(1); - }); + // it("can take the guarded auto-flush branch in finally when forced", () => { + // const scheduler = createEffectScheduler( + // EffectSchedulerMode.Flush + // ) as unknown as typeof createEffectScheduler & { + // shouldAutoFlush(): boolean; + // }; + // const spy = vi.fn(() => {}); + // const node = createWatcherNode(spy); + + // scheduler.shouldAutoFlush = () => true; + // scheduler.enqueue(node); + // scheduler.flush(); + + // expect(spy).toHaveBeenCalledTimes(1); + // }); it("subscribes, unsubscribes, and keeps double unsubscribe safe", () => { const source = new EventSource(); diff --git a/packages/reflex/tests/reflex.scheduling.test.ts b/packages/reflex/tests/reflex.scheduling.test.ts index 2264508..288e1b9 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -18,14 +18,11 @@ vi.mock("@reflex/runtime", async () => { }; }); -import { - DIRTY_STATE, - ReactiveNodeState, -} from "@reflex/runtime"; +import { DIRTY_STATE, ReactiveNodeState } from "@reflex/runtime"; import { createEffectScheduler, EffectSchedulerMode, -} from "../src/policy/effect_scheduler"; +} from "../src/policy/scheduler"; function createContext( overrides: Partial = {}, @@ -79,7 +76,46 @@ describe("createEffectScheduler", () => { scheduler.flush(); expect((node.state & ReactiveNodeState.Scheduled) !== 0).toBe(false); - expect(mocks.runWatcher).not.toHaveBeenCalled(); + + // calls because early exit in runWatcher + // expect(mocks.runWatcher).not.toHaveBeenCalled(); + }); + + it("ranked flush runs higher-priority nodes first and keeps FIFO for ties", () => { + const scheduler = createEffectScheduler(EffectSchedulerMode.Ranked); + const low = createNode() as any; + const high = createNode() as any; + const midA = createNode() as any; + const midB = createNode() as any; + + low.priority = 1; + high.priority = 10; + midA.priority = 5; + midB.priority = 5; + + scheduler.enqueue(midA); + scheduler.enqueue(low); + scheduler.enqueue(high); + scheduler.enqueue(midB); + scheduler.flush(); + + expect(mocks.runWatcher.mock.calls.map(([node]) => node)).toEqual([ + high, + midA, + midB, + low, + ]); + }); + + it("flush runs dirty nodes even when extra state bits are present", () => { + const scheduler = createEffectScheduler(EffectSchedulerMode.Flush); + const node = createNode(DIRTY_STATE | ReactiveNodeState.Changed); + + scheduler.enqueue(node); + scheduler.flush(); + + expect(mocks.runWatcher).toHaveBeenCalledTimes(1); + expect(mocks.runWatcher).toHaveBeenCalledWith(node); }); it("runs immediately in eager mode when context is idle", () => { @@ -111,10 +147,49 @@ describe("createEffectScheduler", () => { expect((b.state & ReactiveNodeState.Scheduled) !== 0).toBe(false); }); + it("can flush on outermost batch exit in sab mode", () => { + const scheduler = createEffectScheduler(EffectSchedulerMode.SAB); + const a = createNode(); + const b = createNode(); + + scheduler.batch(() => { + scheduler.enqueue(a); + scheduler.enqueue(b); + + expect(mocks.runWatcher).not.toHaveBeenCalled(); + expect((a.state & ReactiveNodeState.Scheduled) !== 0).toBe(true); + expect((b.state & ReactiveNodeState.Scheduled) !== 0).toBe(true); + }); + + expect(mocks.runWatcher).toHaveBeenCalledTimes(2); + expect((a.state & ReactiveNodeState.Scheduled) !== 0).toBe(false); + expect((b.state & ReactiveNodeState.Scheduled) !== 0).toBe(false); + }); + + it("keeps sab effects queued when batch exits during active propagation", () => { + const context = createContext({ propagationDepth: 1 }); + mocks.getDefaultContext.mockReturnValue(context); + + const scheduler = createEffectScheduler(EffectSchedulerMode.SAB); + const node = createNode(); + + scheduler.batch(() => { + scheduler.enqueue(node); + }); + + expect(mocks.runWatcher).not.toHaveBeenCalled(); + expect((node.state & ReactiveNodeState.Scheduled) !== 0).toBe(true); + + context.propagationDepth = 0; + scheduler.flush(); + + expect(mocks.runWatcher).toHaveBeenCalledTimes(1); + expect((node.state & ReactiveNodeState.Scheduled) !== 0).toBe(false); + }); + it("does not auto-flush while propagation is active", () => { - mocks.getDefaultContext.mockReturnValue( - createContext({ propagationDepth: 1 }), - ); + const context = createContext({ propagationDepth: 1 }); + mocks.getDefaultContext.mockReturnValue(context); const scheduler = createEffectScheduler(EffectSchedulerMode.Eager); const node = createNode(); @@ -124,7 +199,7 @@ describe("createEffectScheduler", () => { expect(mocks.runWatcher).not.toHaveBeenCalled(); expect((node.state & ReactiveNodeState.Scheduled) !== 0).toBe(true); - mocks.getDefaultContext.mockReturnValue(createContext()); + context.propagationDepth = 0; scheduler.notifySettled(); expect(mocks.runWatcher).toHaveBeenCalledTimes(1); @@ -155,15 +230,65 @@ describe("createEffectScheduler", () => { }); it("reset allows previously queued node to be scheduled again", () => { - const scheduler = createEffectScheduler(EffectSchedulerMode.Flush); - const node = createNode(); + const scheduler = createEffectScheduler(EffectSchedulerMode.Flush); + const node = createNode(); - scheduler.enqueue(node); - scheduler.reset(); + scheduler.enqueue(node); + scheduler.reset(); - scheduler.enqueue(node); - scheduler.flush(); + scheduler.enqueue(node); + scheduler.flush(); + + expect(mocks.runWatcher).toHaveBeenCalledTimes(1); + }); + + it("preserves FIFO order after buffer wrap-around during flush", () => { + const scheduler = createEffectScheduler(EffectSchedulerMode.Flush); + const initial = Array.from({ length: 16 }, () => createNode()); + const deferred = Array.from({ length: 8 }, () => createNode()); + + for (const node of initial) { + scheduler.enqueue(node); + } + + let deferredIndex = 0; + mocks.runWatcher.mockImplementation((node) => { + if ( + deferredIndex < deferred.length && + node === initial[deferredIndex] + ) { + scheduler.enqueue(deferred[deferredIndex]!); + deferredIndex += 1; + } + }); + + scheduler.flush(); - expect(mocks.runWatcher).toHaveBeenCalledTimes(1); + expect(mocks.runWatcher.mock.calls.map(([node]) => node)).toEqual([ + ...initial, + ...deferred, + ]); + }); + + it("drains long linear invalidation chains without skipping nodes", () => { + const scheduler = createEffectScheduler(EffectSchedulerMode.Flush); + const depth = 192; + const nodes = Array.from({ length: depth }, () => createNode()); + const seen: number[] = []; + + mocks.runWatcher.mockImplementation((node) => { + const index = nodes.indexOf(node); + seen.push(index); + + const next = nodes[index + 1]; + if (next !== undefined) { + scheduler.enqueue(next); + } + }); + + scheduler.enqueue(nodes[0]!); + scheduler.flush(); + + expect(seen).toEqual(Array.from({ length: depth }, (_, i) => i)); + }); }); -}); \ No newline at end of file diff --git a/packages/reflex/tests/reflex.watcher_queue.test.ts b/packages/reflex/tests/reflex.watcher_queue.test.ts new file mode 100644 index 0000000..b9db31f --- /dev/null +++ b/packages/reflex/tests/reflex.watcher_queue.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { createWatcherNode } from "../src/infra/factory"; +import { createWatcherQueue } from "../src/policy/scheduler"; + +function createNodes(count: number) { + return Array.from({ length: count }, () => createWatcherNode(() => {})); +} + +describe("createWatcherQueue", () => { + it("starts empty and shifts null", () => { + const queue = createWatcherQueue(); + + expect(queue.size).toBe(0); + expect(queue.head).toBe(0); + expect(queue.tail).toBe(0); + expect(queue.shift()).toBeNull(); + }); + + it("preserves FIFO order without growth", () => { + const queue = createWatcherQueue(); + const nodes = createNodes(4); + + for (const node of nodes) { + queue.push(node); + } + + expect(queue.size).toBe(4); + expect(queue.ring.length).toBe(16); + + for (const node of nodes) { + expect(queue.shift()).toBe(node); + } + + expect(queue.size).toBe(0); + expect(queue.shift()).toBeNull(); + }); + + it("grows from the initial capacity and preserves order", () => { + const queue = createWatcherQueue(); + const nodes = createNodes(20); + + for (const node of nodes) { + queue.push(node); + } + + expect(queue.size).toBe(20); + expect(queue.ring.length).toBe(32); + + for (const node of nodes) { + expect(queue.shift()).toBe(node); + } + + expect(queue.size).toBe(0); + }); + + it("preserves FIFO order after wrap-around growth", () => { + const queue = createWatcherQueue(); + const initial = createNodes(16); + const wrapped = createNodes(9); + + for (const node of initial) { + queue.push(node); + } + + for (const node of initial.slice(0, 8)) { + expect(queue.shift()).toBe(node); + } + + for (const node of wrapped) { + queue.push(node); + } + + expect(queue.size).toBe(17); + expect(queue.ring.length).toBe(32); + + for (const node of initial.slice(8)) { + expect(queue.shift()).toBe(node); + } + + for (const node of wrapped) { + expect(queue.shift()).toBe(node); + } + + expect(queue.size).toBe(0); + }); + + it("clear resets indices and allows reuse", () => { + const queue = createWatcherQueue(); + const first = createNodes(3); + const second = createNodes(2); + + for (const node of first) { + queue.push(node); + } + + queue.clear(); + + expect(queue.size).toBe(0); + expect(queue.head).toBe(0); + expect(queue.tail).toBe(0); + expect(queue.shift()).toBeNull(); + + for (const node of second) { + queue.push(node); + } + + expect(queue.shift()).toBe(second[0]); + expect(queue.shift()).toBe(second[1]); + expect(queue.shift()).toBeNull(); + }); +}); diff --git a/packages/reflex/tsconfig.type-tests.json b/packages/reflex/tsconfig.type-tests.json new file mode 100644 index 0000000..070e719 --- /dev/null +++ b/packages/reflex/tsconfig.type-tests.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.typecheck.json", + "include": ["src", "tests/reflex.model.typecheck.ts"] +}