From d340771f95189b8819bb1562f94a249e6a1887c7 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Wed, 8 Apr 2026 23:38:53 +0300 Subject: [PATCH 01/14] little refactor --- .../runtime/src/reactivity/shape/methods/connect.ts | 2 +- .../runtime/src/reactivity/walkers/propagate.once.ts | 5 +++-- .../@reflex/runtime/src/reactivity/walkers/propagate.ts | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts index 666f147e..ad402aed 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts @@ -172,7 +172,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.once.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts index 6963a6df..de00039b 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -3,6 +3,7 @@ import { defaultContext } 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,9 @@ export function propagateOnce(node: ReactiveNode): void { return; } + let thrown: unknown = null; const context = defaultContext; const dispatch = context.effectInvalidatedDispatch; - let thrown: unknown = null; for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { const sub = edge.to; @@ -32,7 +33,7 @@ export function propagateOnce(node: ReactiveNode): void { }); } - if ((nextState & ReactiveNodeState.Watcher) === 0) continue; + if ((nextState & WATCHER_MASK) === 0) continue; if (__DEV__) { recordDebugEvent(context, "watcher:invalidated", { node: sub }); diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index f8abffc8..8e868635 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -64,7 +64,8 @@ export function propagate( return; } - const dispatch = defaultContext.effectInvalidatedDispatch; + const context = defaultContext; + const dispatch = context.effectInvalidatedDispatch; const edgeStack = propagateEdgeStack; const stackBase = edgeStack.length; let stackTop = stackBase; @@ -87,7 +88,7 @@ export function propagate( sub.state = nextState; if (__DEV__) { - recordDebugEvent(defaultContext, "propagate", { + recordDebugEvent(context, "propagate", { detail: { immediate: promoteBit === IMMEDIATE, nextState }, source: edge.from, target: sub, @@ -111,7 +112,7 @@ export function propagate( } } } else if (__DEV__) { - recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); + recordDebugEvent(context, "watcher:invalidated", { node: sub }); } } From a884e81c88172ab203afe2979ae56995a8a5fbb3 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Thu, 9 Apr 2026 11:53:51 +0300 Subject: [PATCH 02/14] small fixes for recompute --- .../@reflex/runtime/src/reactivity/context.ts | 261 +++++------------- .../reactivity/walkers/recompute.branch.ts | 119 +++++++- .../reactivity/walkers/recompute.branching.ts | 75 ----- .../runtime/tests/perf/walkers.jit.mjs | 2 +- 4 files changed, 177 insertions(+), 280 deletions(-) delete mode 100644 packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts diff --git a/packages/@reflex/runtime/src/reactivity/context.ts b/packages/@reflex/runtime/src/reactivity/context.ts index eaad9f8b..f258027a 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -11,172 +11,51 @@ export type CleanupRegistrar = (cleanup: () => void) => void; 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 class ExecutionContext { activeComputed: ReactiveNode | null = null; propagationDepth = 0; cleanupRegistrar: CleanupRegistrar | null = null; - readonly hooks: EngineHooks; - onEffectInvalidatedHook: OnEffectInvalidatedHook = undefined; - onReactiveSettledHook: OnReactiveSettledHook = undefined; - runtimeOnEffectInvalidatedHook: OnEffectInvalidatedHook = undefined; - runtimeOnReactiveSettledHook: OnReactiveSettledHook = undefined; + + 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 = {}) { this.setHooks(hooks); } 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(); } @@ -186,29 +65,29 @@ export class ExecutionContext { 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(); + 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,57 +95,51 @@ 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 = + 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 = {}, ): ExecutionContext { return new ExecutionContext(hooks); } @@ -281,10 +154,6 @@ export function setDefaultContext(context: ExecutionContext): ExecutionContext { return previous; } -export function resetDefaultContext( - hooks: EngineHooks = EMPTY_HOOKS, -): ExecutionContext { - const next = new ExecutionContext(hooks); - defaultContext = next; - return next; +export function resetDefaultContext(hooks: EngineHooks = {}): ExecutionContext { + return (defaultContext = new ExecutionContext(hooks)); } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts index cd6882d1..02e776e9 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts @@ -15,14 +15,113 @@ 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 (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) { + const dep = link.from; + const depState = dep.state; + const fanout = hasFanout(link); + + if ((consumer.state & ReactiveNodeState.Changed) !== 0) { + changed = true; + } else if ((depState & ReactiveNodeState.Changed) !== 0) { + // Already-confirmed computed dependency: refresh and stop searching. + changed = refreshAndPropagateIfNeeded(dep, fanout); + } else if ((depState & DIRTY_STATE) !== 0) { + const deps = dep.firstIn; + if (deps !== null) { + stack[stackTop++] = link; + link = deps; + consumer = dep; + continue; + } + + changed = refreshAndPropagateIfNeeded(dep, fanout); + } + + 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 +135,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( @@ -55,11 +156,12 @@ export function shouldRecomputeLinear( const dep = link.from; const depState = dep.state; + const fanout = hasFanout(link); if ((depState & ReactiveNodeState.Changed) !== 0) { - changed = refreshRecompute(link, dep); + changed = refreshAndPropagateIfFanout(dep, fanout); - if (changed || link.nextIn === null) break; + if (changed || nextIn === null) break; } if ((depState & DIRTY_STATE) !== 0) { @@ -89,7 +191,7 @@ export function shouldRecomputeLinear( continue; } - changed = refreshRecompute(link, dep); + changed = refreshAndPropagateIfFanout(dep, fanout); break; } @@ -98,6 +200,7 @@ export function shouldRecomputeLinear( if (stackTop === stackBase) { // Stack empty: nothing changed anymore. + stack.length = stackBase; return false; } @@ -108,9 +211,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 +228,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 5897ac33..00000000 --- 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/tests/perf/walkers.jit.mjs b/packages/@reflex/runtime/tests/perf/walkers.jit.mjs index 450a749e..ebc7a33f 100644 --- a/packages/@reflex/runtime/tests/perf/walkers.jit.mjs +++ b/packages/@reflex/runtime/tests/perf/walkers.jit.mjs @@ -17,7 +17,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(); From bcca63b2d037318949f92f4fcd443b3640cb33b2 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Thu, 9 Apr 2026 12:58:26 +0300 Subject: [PATCH 03/14] refactor --- .../src/reactivity/walkers/propagate.once.ts | 59 ++-- .../src/reactivity/walkers/propagate.ts | 178 +++++++--- .../reactivity/walkers/recompute.branch.ts | 41 ++- .../runtime/tests/perf/walkers.jit.mjs | 317 ++++++++++++++++++ .../runtime/tests/runtime.security.test.ts | 15 +- .../runtime/tests/runtime.walkers.test.ts | 98 ++++++ packages/reflex/src/unstable/index.ts | 1 + 7 files changed, 619 insertions(+), 90 deletions(-) diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts index de00039b..244ff411 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -13,33 +13,54 @@ export function propagateOnce(node: ReactiveNode): void { let thrown: unknown = null; const context = defaultContext; - const dispatch = context.effectInvalidatedDispatch; + const dispatch = context.dispatchWatcherEvent; - for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { - const sub = edge.to; - const state = sub.state; + if (dispatch === undefined) { + for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { + const sub = edge.to; + const state = sub.state; - if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; + if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; - const nextState = - (state & ~ReactiveNodeState.Invalid) | ReactiveNodeState.Changed; - sub.state = nextState; + const nextState = state ^ DIRTY_STATE; + sub.state = nextState; - if (__DEV__) { - recordDebugEvent(context, "propagate", { - detail: { immediate: true, nextState }, - source: edge.from, - target: sub, - }); + if (__DEV__) { + recordDebugEvent(context, "propagate", { + detail: { immediate: true, nextState }, + source: edge.from, + target: sub, + }); + + if ((nextState & WATCHER_MASK) !== 0) { + recordDebugEvent(context, "watcher:invalidated", { node: sub }); + } + } } + } else { + for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { + const sub = edge.to; + const state = sub.state; - if ((nextState & WATCHER_MASK) === 0) continue; + if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; - if (__DEV__) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); - } + const nextState = state ^ DIRTY_STATE; + sub.state = nextState; + + if (__DEV__) { + recordDebugEvent(context, "propagate", { + detail: { immediate: true, nextState }, + source: edge.from, + target: sub, + }); + } + + if ((nextState & WATCHER_MASK) === 0) continue; + + if (__DEV__) { + recordDebugEvent(context, "watcher:invalidated", { node: sub }); + } - if (dispatch !== undefined) { try { dispatch(sub); } catch (error) { diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index 8e868635..55b16db5 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -20,8 +20,9 @@ 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 getSlowInvalidatedSubscriberState( edge: ReactiveEdge, sub: ReactiveNode, subState: number, @@ -53,83 +54,176 @@ 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 context = defaultContext; - const dispatch = context.effectInvalidatedDispatch; +function propagateBranching( + edge: ReactiveEdge, + promote: number, + dispatch: ((node: ReactiveNode) => void) | undefined, + context: typeof defaultContext, + 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 resume: ReactiveEdge | null = edge.nextOut; + let resumePromote = promote; - 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; + 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(context, "propagate", { - detail: { immediate: promoteBit === IMMEDIATE, nextState }, + detail: { immediate: promote === IMMEDIATE, nextState }, source: edge.from, target: sub, }); } - if ((nextState & WATCHER_MASK) === 0) { + if ((nextState & WATCHER_MASK) !== 0) { + if (dispatch !== undefined) { + try { + dispatch(sub); + } catch (error) { + if (thrown === null) { + thrown = error; + } + } + } else if (__DEV__) { + recordDebugEvent(context, "watcher:invalidated", { node: sub }); + } + } else { const firstOut = sub.firstOut; - if (firstOut !== null) { - if (next !== null) edgeStack[stackTop++] = next; + if (resume !== null) { + edgeStack[stackTop] = resume; + promoteStack[stackTop++] = resumePromote; + } + edge = firstOut; + resume = edge.nextOut; + promote = resumePromote = NON_IMMEDIATE; continue; } - } else if (dispatch !== undefined) { - try { - dispatch(sub); - } catch (error) { - if (thrown === null) { - thrown = error; - } - } - } else if (__DEV__) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); } } - if (next !== null) { - edge = next; + if (resume !== null) { + edge = resume; + promote = resumePromote; + resume = edge.nextOut; continue; } if (stackTop !== stackBase) { edge = edgeStack[--stackTop]!; + promote = resumePromote = promoteStack[stackTop]!; + resume = edge.nextOut; continue; } - break; + edgeStack.length = stackBase; + promoteStack.length = stackBase; + return thrown; } +} + +function propagateLinear( + edge: ReactiveEdge, + promote: number, + dispatch: ((node: ReactiveNode) => void) | undefined, + context: typeof defaultContext, +): unknown { + let thrown: unknown = null; - edgeStack.length = stackBase; + 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(context, "propagate", { + detail: { immediate: promote === IMMEDIATE, nextState }, + source: edge.from, + target: sub, + }); + } + + if ((nextState & WATCHER_MASK) !== 0) { + if (dispatch !== undefined) { + try { + dispatch(sub); + } catch (error) { + if (thrown === null) { + thrown = error; + } + } + } else if (__DEV__) { + recordDebugEvent(context, "watcher:invalidated", { node: sub }); + } + } else { + const firstOut = sub.firstOut; + if (firstOut !== null) { + edge = firstOut; + + if (next !== null) { + return propagateBranching( + edge, + NON_IMMEDIATE, + dispatch, + context, + thrown, + next, + promote, + ); + } + + promote = NON_IMMEDIATE; + continue; + } + } + } + + 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; + } + + const context = defaultContext; + const dispatch = context.dispatchWatcherEvent; + const thrown = propagateLinear(startEdge, promoteImmediate, dispatch, context); 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 02e776e9..adc6ca53 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts @@ -67,25 +67,26 @@ function shouldRecomputeBranching( let changed = false; outer: while (true) { - const dep = link.from; - const depState = dep.state; - const fanout = hasFanout(link); - if ((consumer.state & ReactiveNodeState.Changed) !== 0) { changed = true; - } else if ((depState & ReactiveNodeState.Changed) !== 0) { - // Already-confirmed computed dependency: refresh and stop searching. - changed = refreshAndPropagateIfNeeded(dep, fanout); - } else if ((depState & DIRTY_STATE) !== 0) { - const deps = dep.firstIn; - if (deps !== null) { - stack[stackTop++] = link; - link = deps; - consumer = dep; - continue; - } + } 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, fanout); + changed = refreshAndPropagateIfNeeded(dep, hasFanout(link)); + } } if (!changed) { @@ -156,12 +157,10 @@ export function shouldRecomputeLinear( const dep = link.from; const depState = dep.state; - const fanout = hasFanout(link); if ((depState & ReactiveNodeState.Changed) !== 0) { - changed = refreshAndPropagateIfFanout(dep, fanout); - - if (changed || nextIn === null) break; + changed = refreshAndPropagateIfFanout(dep, hasFanout(link)); + break; } if ((depState & DIRTY_STATE) !== 0) { @@ -191,7 +190,7 @@ export function shouldRecomputeLinear( continue; } - changed = refreshAndPropagateIfFanout(dep, fanout); + changed = refreshAndPropagateIfFanout(dep, hasFanout(link)); break; } diff --git a/packages/@reflex/runtime/tests/perf/walkers.jit.mjs b/packages/@reflex/runtime/tests/perf/walkers.jit.mjs index ebc7a33f..1d7a5eac 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"; @@ -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.security.test.ts b/packages/@reflex/runtime/tests/runtime.security.test.ts index d654ede8..7249089c 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.walkers.test.ts b/packages/@reflex/runtime/tests/runtime.walkers.test.ts index 8479c81b..581599f6 100644 --- a/packages/@reflex/runtime/tests/runtime.walkers.test.ts +++ b/packages/@reflex/runtime/tests/runtime.walkers.test.ts @@ -199,6 +199,29 @@ describe("Reactive runtime - walker invariants", () => { ); }); + 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!, IMMEDIATE); + + expect(middle.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Changed, + ); + expect(leaf.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Invalid, + ); + }); + it("propagateOnce upgrades only pure Invalid subscribers and notifies watchers once", () => { const source = createNode(ReactiveNodeState.Producer); const consumer = createNode( @@ -237,6 +260,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 +396,54 @@ describe("Reactive runtime - walker invariants", () => { expect(right.state & ReactiveNodeState.Invalid).toBeFalsy(); }); + 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/src/unstable/index.ts b/packages/reflex/src/unstable/index.ts index fa0336b5..e81a9c1f 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"; From 2d22f0eb73b5c73b5f7ed6b0bbe7e17bd2a19675 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Thu, 9 Apr 2026 16:36:02 +0300 Subject: [PATCH 04/14] Change semantic of scheduler, added ring buffer instead just array --- packages/reflex/bench/tail-index.bench.ts | 181 ++++++++++++++++++ .../reflex/src/policy/effect_scheduler.ts | 122 ++++++++---- packages/reflex/tests/reflex.effects.test.ts | 36 ++++ .../reflex/tests/reflex.scheduling.test.ts | 82 ++++++-- 4 files changed, 369 insertions(+), 52 deletions(-) create mode 100644 packages/reflex/bench/tail-index.bench.ts diff --git a/packages/reflex/bench/tail-index.bench.ts b/packages/reflex/bench/tail-index.bench.ts new file mode 100644 index 00000000..f30a4f9b --- /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/src/policy/effect_scheduler.ts b/packages/reflex/src/policy/effect_scheduler.ts index e2a53a63..c570ba90 100644 --- a/packages/reflex/src/policy/effect_scheduler.ts +++ b/packages/reflex/src/policy/effect_scheduler.ts @@ -46,42 +46,91 @@ export interface EffectScheduler { function noopNotifySettled(): void {} +const SCHEDULED_DISPOSED = + ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled; +const INITIAL_QUEUE_CAPACITY = 16; + export function createEffectScheduler( mode: EffectSchedulerMode = EffectSchedulerMode.Flush, context?: ExecutionContext, ): EffectScheduler { + let head = 0; + let tail = 0; + let size = 0; + 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 hasPending(): boolean { + return size !== 0; + } + + function growQueue(): void { + const capacity = queue.length; + const nextCapacity = + capacity === 0 ? INITIAL_QUEUE_CAPACITY : capacity << 1; + + const nextQueue = new Array< + ReactiveNode + >(nextCapacity); + + for (let i = 0; i < size; ++i) { + nextQueue[i] = queue[(head + i) % capacity]!; + } + + queue.length = nextCapacity; + for (let i = 0; i < size; ++i) { + queue[i] = nextQueue[i]!; + } + + head = 0; + tail = size; + } + + function push(node: ReactiveNode): void { + if (size === queue.length) { + growQueue(); + } + + queue[tail] = node; + tail = (tail + 1) % queue.length; + ++size; + } + + function shift(): ReactiveNode | null { + if (size === 0) { + return null; + } + + const node = queue[head]!; + queue[head] = undefined as never; + head = (head + 1) % queue.length; + --size; + return node; + } + function enqueueFlush(node: ReactiveNode): void { const state = node.state; - if ( - (state & (ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled)) !== - 0 - ) { + if ((state & SCHEDULED_DISPOSED) !== 0) { return; } node.state = state | ReactiveNodeState.Scheduled; - queue.push(node); + push(node); } function enqueueEager(node: ReactiveNode): void { const state = node.state; - if ( - (state & (ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled)) !== - 0 - ) { + if ((state & SCHEDULED_DISPOSED) !== 0) { return; } node.state = state | ReactiveNodeState.Scheduled; - queue.push(node); + push(node); const currentContext = getContext(); if ( @@ -113,7 +162,7 @@ export function createEffectScheduler( phase = SchedulerPhase.Idle; - if (head < queue.length) { + if (hasPending()) { flushQueue(); } } @@ -130,45 +179,40 @@ export function createEffectScheduler( } } - 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; + if (!hasPending()) return; phase = SchedulerPhase.Flushing; try { - drainQueue(); + while (size !== 0) { + const node = shift()!; + const state = node.state & ~ReactiveNodeState.Scheduled; + node.state = state; + + if ( + (state & ReactiveNodeState.Disposed) === 0 && + (state & DIRTY_STATE) !== 0 + ) { + runWatcher(node); + } + } } finally { - queue.length = 0; - head = 0; + head = tail = size = 0; phase = batchDepth > 0 ? SchedulerPhase.Batching : SchedulerPhase.Idle; } } function notifySettledEager(): void { const currentContext = getContext(); - if ( + const inactive = phase === SchedulerPhase.Idle && batchDepth === 0 && currentContext.propagationDepth === 0 && - currentContext.activeComputed === null && - head < queue.length - ) { + currentContext.activeComputed === null; + + if (inactive && hasPending()) { flushQueue(); } } @@ -177,13 +221,11 @@ export function createEffectScheduler( const notifySettled = eager ? notifySettledEager : noopNotifySettled; function reset(): void { - for (let i = head; i < queue.length; ++i) { - queue[i]!.state &= ~ReactiveNodeState.Scheduled; + while (size !== 0) { + shift()!.state &= ~ReactiveNodeState.Scheduled; } - queue.length = 0; - head = 0; - batchDepth = 0; + head = tail = size = batchDepth = 0; phase = SchedulerPhase.Idle; } diff --git a/packages/reflex/tests/reflex.effects.test.ts b/packages/reflex/tests/reflex.effects.test.ts index c4196aa6..df6bd0e3 100644 --- a/packages/reflex/tests/reflex.effects.test.ts +++ b/packages/reflex/tests/reflex.effects.test.ts @@ -66,6 +66,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.scheduling.test.ts b/packages/reflex/tests/reflex.scheduling.test.ts index 22645083..1868a314 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -18,10 +18,7 @@ vi.mock("@reflex/runtime", async () => { }; }); -import { - DIRTY_STATE, - ReactiveNodeState, -} from "@reflex/runtime"; +import { DIRTY_STATE, ReactiveNodeState } from "@reflex/runtime"; import { createEffectScheduler, EffectSchedulerMode, @@ -82,6 +79,17 @@ describe("createEffectScheduler", () => { expect(mocks.runWatcher).not.toHaveBeenCalled(); }); + 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", () => { const scheduler = createEffectScheduler(EffectSchedulerMode.Eager); const node = createNode(); @@ -155,15 +163,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.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.enqueue(node); - scheduler.reset(); + scheduler.flush(); + + 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(node); - scheduler.flush(); + scheduler.enqueue(nodes[0]!); + scheduler.flush(); - expect(mocks.runWatcher).toHaveBeenCalledTimes(1); + expect(seen).toEqual(Array.from({ length: depth }, (_, i) => i)); + }); }); -}); \ No newline at end of file From dbef6f8171a2faa364504f17eaab02091419f8fd Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Thu, 9 Apr 2026 17:14:09 +0300 Subject: [PATCH 05/14] fix dev bug --- packages/@reflex/runtime/src/debug.ts | 36 +++++++++++++++++-- .../@reflex/runtime/src/reactivity/context.ts | 5 ++- .../src/reactivity/shape/ReactiveNode.ts | 11 ++++-- .../src/reactivity/walkers/propagate.once.ts | 13 ++++--- .../src/reactivity/walkers/propagate.ts | 22 +++++------- packages/@reflex/runtime/vite.config.ts | 2 +- packages/reflex/src/api/event.ts | 7 ++-- 7 files changed, 65 insertions(+), 31 deletions(-) diff --git a/packages/@reflex/runtime/src/debug.ts b/packages/@reflex/runtime/src/debug.ts index 95a18b63..7bab2d61 100644 --- a/packages/@reflex/runtime/src/debug.ts +++ b/packages/@reflex/runtime/src/debug.ts @@ -112,9 +112,15 @@ interface RuntimeDebugEventInput { target?: ReactiveNode; } -const contextStates = new WeakMap(); +const contextStates = new WeakMap(); const nodeIds = new WeakMap(); const nodeLabels = new WeakMap(); +const invalidContextKey = {}; +const invalidNodeIds = new Map(); + +function isObjectKey(value: unknown): value is object { + return (typeof value === "object" || typeof value === "function") && value !== null; +} let nextContextId = 1; let nextNodeId = 1; @@ -162,8 +168,13 @@ function getFlags(state: number): RuntimeDebugFlag[] { return flags; } +function normalizeContextKey(context: ExecutionContext): object { + return isObjectKey(context) ? context : invalidContextKey; +} + function ensureContextState(context: ExecutionContext): RuntimeDebugState { - const existing = contextStates.get(context); + const key = normalizeContextKey(context); + const existing = contextStates.get(key); if (existing) return existing; @@ -175,11 +186,20 @@ function ensureContextState(context: ExecutionContext): RuntimeDebugState { listeners: new Set(), }; - contextStates.set(context, state); + 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; @@ -190,6 +210,16 @@ function ensureNodeId(node: ReactiveNode): number { } 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), diff --git a/packages/@reflex/runtime/src/reactivity/context.ts b/packages/@reflex/runtime/src/reactivity/context.ts index f258027a..9ed95401 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -1,6 +1,7 @@ import type { ReactiveNode } from "./shape"; import { recordDebugEvent } from "../debug"; + export interface EngineHooks { onEffectInvalidated?(node: ReactiveNode): void; onReactiveSettled?(): void; @@ -13,6 +14,8 @@ type OnReactiveSettledHook = EngineHooks["onReactiveSettled"]; const IS_DEV = typeof __DEV__ !== "undefined" && __DEV__; +export let dispatchEffectInvalidated = undefined as OnEffectInvalidatedHook; + export class ExecutionContext { activeComputed: ReactiveNode | null = null; propagationDepth = 0; @@ -110,7 +113,7 @@ export class ExecutionContext { const rs = this.runtimeOnReactiveSettled, ps = this.onReactiveSettled; - this.effectInvalidatedDispatch = + dispatchEffectInvalidated = this.effectInvalidatedDispatch = ri && pi ? function (node) { ri(node); diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts index ff51a6ac..411ccb95 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts @@ -3,7 +3,14 @@ import type { ReactiveEdge } from "./ReactiveEdge"; 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 +32,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/walkers/propagate.once.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts index 244ff411..ae63955e 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -1,5 +1,5 @@ import { recordDebugEvent } from "../../debug"; -import { defaultContext } from "../context"; +import { defaultContext, dispatchEffectInvalidated } from "../context"; import { devAssertPropagateAlive } from "../dev"; import type { ReactiveNode } from "../shape"; import { DIRTY_STATE, ReactiveNodeState } from "../shape"; @@ -12,8 +12,7 @@ export function propagateOnce(node: ReactiveNode): void { } let thrown: unknown = null; - const context = defaultContext; - const dispatch = context.dispatchWatcherEvent; + const dispatch = dispatchEffectInvalidated; if (dispatch === undefined) { for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { @@ -26,14 +25,14 @@ export function propagateOnce(node: ReactiveNode): void { sub.state = nextState; if (__DEV__) { - recordDebugEvent(context, "propagate", { + recordDebugEvent(defaultContext, "propagate", { detail: { immediate: true, nextState }, source: edge.from, target: sub, }); if ((nextState & WATCHER_MASK) !== 0) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); } } } @@ -48,7 +47,7 @@ export function propagateOnce(node: ReactiveNode): void { sub.state = nextState; if (__DEV__) { - recordDebugEvent(context, "propagate", { + recordDebugEvent(defaultContext, "propagate", { detail: { immediate: true, nextState }, source: edge.from, target: sub, @@ -58,7 +57,7 @@ export function propagateOnce(node: ReactiveNode): void { if ((nextState & WATCHER_MASK) === 0) continue; if (__DEV__) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); } try { diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index 55b16db5..60f89a99 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 { defaultContext, dispatchEffectInvalidated } from "../context"; import { devAssertPropagateAlive } from "../dev"; import { DIRTY_STATE, @@ -57,8 +57,6 @@ function getSlowInvalidatedSubscriberState( function propagateBranching( edge: ReactiveEdge, promote: number, - dispatch: ((node: ReactiveNode) => void) | undefined, - context: typeof defaultContext, thrown: unknown, parentResume: ReactiveEdge | null, parentResumePromote: number, @@ -69,6 +67,7 @@ function propagateBranching( let stackTop = stackBase; let resume: ReactiveEdge | null = edge.nextOut; let resumePromote = promote; + const dispatch = dispatchEffectInvalidated; if (parentResume !== null) { edgeStack[stackTop] = parentResume; @@ -87,7 +86,7 @@ function propagateBranching( sub.state = nextState; if (__DEV__) { - recordDebugEvent(context, "propagate", { + recordDebugEvent(defaultContext, "propagate", { detail: { immediate: promote === IMMEDIATE, nextState }, source: edge.from, target: sub, @@ -104,7 +103,7 @@ function propagateBranching( } } } else if (__DEV__) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); } } else { const firstOut = sub.firstOut; @@ -145,10 +144,9 @@ function propagateBranching( function propagateLinear( edge: ReactiveEdge, promote: number, - dispatch: ((node: ReactiveNode) => void) | undefined, - context: typeof defaultContext, ): unknown { let thrown: unknown = null; + const dispatch = dispatchEffectInvalidated; while (true) { const sub = edge.to; @@ -163,7 +161,7 @@ function propagateLinear( sub.state = nextState; if (__DEV__) { - recordDebugEvent(context, "propagate", { + recordDebugEvent(defaultContext, "propagate", { detail: { immediate: promote === IMMEDIATE, nextState }, source: edge.from, target: sub, @@ -180,7 +178,7 @@ function propagateLinear( } } } else if (__DEV__) { - recordDebugEvent(context, "watcher:invalidated", { node: sub }); + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); } } else { const firstOut = sub.firstOut; @@ -191,8 +189,6 @@ function propagateLinear( return propagateBranching( edge, NON_IMMEDIATE, - dispatch, - context, thrown, next, promote, @@ -221,9 +217,7 @@ export function propagate( return; } - const context = defaultContext; - const dispatch = context.dispatchWatcherEvent; - const thrown = propagateLinear(startEdge, promoteImmediate, dispatch, context); + const thrown = propagateLinear(startEdge, promoteImmediate); if (thrown !== null) throw thrown; } diff --git a/packages/@reflex/runtime/vite.config.ts b/packages/@reflex/runtime/vite.config.ts index 2a322118..b3ab539a 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/src/api/event.ts b/packages/reflex/src/api/event.ts index 9487480f..f0cb6922 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 { From 8131e6f8fbe53daea5c5fc7741d5ec251857c0c8 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Fri, 10 Apr 2026 00:04:04 +0300 Subject: [PATCH 06/14] Removed unnecessary branches --- .../@reflex/runtime/src/reactivity/context.ts | 3 +- .../src/reactivity/shape/ReactiveNode.ts | 1 + packages/reflex/src/infra/factory.ts | 22 ++++-- .../reflex/src/policy/effect_scheduler.ts | 78 ++++++++++++------- packages/reflex/tests/reflex.factory.test.ts | 4 +- .../reflex/tests/reflex.scheduling.test.ts | 4 +- 6 files changed, 74 insertions(+), 38 deletions(-) diff --git a/packages/@reflex/runtime/src/reactivity/context.ts b/packages/@reflex/runtime/src/reactivity/context.ts index 9ed95401..a362405f 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -1,7 +1,6 @@ import type { ReactiveNode } from "./shape"; import { recordDebugEvent } from "../debug"; - export interface EngineHooks { onEffectInvalidated?(node: ReactiveNode): void; onReactiveSettled?(): void; @@ -113,7 +112,7 @@ export class ExecutionContext { const rs = this.runtimeOnReactiveSettled, ps = this.onReactiveSettled; - dispatchEffectInvalidated = this.effectInvalidatedDispatch = + this.effectInvalidatedDispatch = dispatchEffectInvalidated = ri && pi ? function (node) { ri(node); diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts index 411ccb95..cffeae20 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts @@ -1,6 +1,7 @@ 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 = diff --git a/packages/reflex/src/infra/factory.ts b/packages/reflex/src/infra/factory.ts index b504716d..76187ad2 100644 --- a/packages/reflex/src/infra/factory.ts +++ b/packages/reflex/src/infra/factory.ts @@ -10,7 +10,11 @@ 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); + return new RuntimeReactiveNode( + payload, + /*TODO: replace with undefined*/ null, + PRODUCER_INITIAL_STATE, + ); } export function createSource(): RuntimeEventSource { @@ -18,17 +22,25 @@ export function createSource(): RuntimeEventSource { } export function createResourceStateNode() { - return new RuntimeReactiveNode(0, null, PRODUCER_INITIAL_STATE); + 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); + 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); + return new RuntimeReactiveNode(undefined as T, fn, CONSUMER_INITIAL_STATE); } export function createWatcherNode(compute: EffectFn): ReactiveNode { - return new RuntimeReactiveNode(null, compute, WATCHER_INITIAL_STATE); + return new RuntimeReactiveNode(undefined, compute, WATCHER_INITIAL_STATE); } diff --git a/packages/reflex/src/policy/effect_scheduler.ts b/packages/reflex/src/policy/effect_scheduler.ts index c570ba90..330060f2 100644 --- a/packages/reflex/src/policy/effect_scheduler.ts +++ b/packages/reflex/src/policy/effect_scheduler.ts @@ -1,5 +1,4 @@ import { - DIRTY_STATE, ReactiveNodeState, runWatcher, getDefaultContext, @@ -7,6 +6,32 @@ import { import type { ExecutionContext, ReactiveNode } from "@reflex/runtime"; import type { UNINITIALIZED } from "../infra/factory"; +/** + * 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: ReactiveNode, +) { + const s = node.state; + node.state = s | 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: ReactiveNode, +) { + const s = node.state; + node.state = s & ~ReactiveNodeState.Scheduled; +} + export const enum EffectSchedulerMode { Flush = 0, Eager = 1, @@ -29,7 +54,7 @@ export function resolveEffectSchedulerMode( } export interface EffectScheduler { - readonly queue: ReactiveNode[]; + readonly ring: ReactiveNode[]; readonly mode: EffectSchedulerMode; readonly context: ExecutionContext; @@ -46,7 +71,7 @@ export interface EffectScheduler { function noopNotifySettled(): void {} -const SCHEDULED_DISPOSED = +const SCHEDULED_OR_DISPOSED = ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled; const INITIAL_QUEUE_CAPACITY = 16; @@ -58,7 +83,7 @@ export function createEffectScheduler( let tail = 0; let size = 0; - const queue: ReactiveNode[] = []; + const ring: ReactiveNode[] = []; const eager = mode === EffectSchedulerMode.Eager; const getContext = context === undefined ? getDefaultContext : () => context; @@ -70,7 +95,7 @@ export function createEffectScheduler( } function growQueue(): void { - const capacity = queue.length; + const capacity = ring.length; const nextCapacity = capacity === 0 ? INITIAL_QUEUE_CAPACITY : capacity << 1; @@ -79,12 +104,12 @@ export function createEffectScheduler( >(nextCapacity); for (let i = 0; i < size; ++i) { - nextQueue[i] = queue[(head + i) % capacity]!; + nextQueue[i] = ring[(head + i) % capacity]!; } - queue.length = nextCapacity; + ring.length = nextCapacity; for (let i = 0; i < size; ++i) { - queue[i] = nextQueue[i]!; + ring[i] = nextQueue[i]!; } head = 0; @@ -92,12 +117,12 @@ export function createEffectScheduler( } function push(node: ReactiveNode): void { - if (size === queue.length) { + if (size === ring.length) { growQueue(); } - queue[tail] = node; - tail = (tail + 1) % queue.length; + ring[tail] = node; + tail = (tail + 1) % ring.length; ++size; } @@ -106,30 +131,30 @@ export function createEffectScheduler( return null; } - const node = queue[head]!; - queue[head] = undefined as never; - head = (head + 1) % queue.length; + const node = ring[head]!; + ring[head] = undefined as never; + head = (head + 1) % ring.length; --size; return node; } function enqueueFlush(node: ReactiveNode): void { const state = node.state; - if ((state & SCHEDULED_DISPOSED) !== 0) { + if ((state & SCHEDULED_OR_DISPOSED) !== 0) { return; } - node.state = state | ReactiveNodeState.Scheduled; + effectScheduled(node); push(node); } function enqueueEager(node: ReactiveNode): void { const state = node.state; - if ((state & SCHEDULED_DISPOSED) !== 0) { + if ((state & SCHEDULED_OR_DISPOSED) !== 0) { return; } - node.state = state | ReactiveNodeState.Scheduled; + effectScheduled(node); push(node); const currentContext = getContext(); @@ -188,15 +213,12 @@ export function createEffectScheduler( try { while (size !== 0) { const node = shift()!; - const state = node.state & ~ReactiveNodeState.Scheduled; - node.state = state; - - if ( - (state & ReactiveNodeState.Disposed) === 0 && - (state & DIRTY_STATE) !== 0 - ) { - runWatcher(node); - } + effectUnscheduled(node); + // if ( + // (state & ReactiveNodeState.Disposed) === 0 && + // (state & DIRTY_STATE) !== 0 + // ) must be there but already guaranties by runWatcher {} + runWatcher(node); } } finally { head = tail = size = 0; @@ -230,7 +252,7 @@ export function createEffectScheduler( } return { - queue, + ring, mode, get context() { return getContext(); diff --git a/packages/reflex/tests/reflex.factory.test.ts b/packages/reflex/tests/reflex.factory.test.ts index 31204bcd..978229cf 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.scheduling.test.ts b/packages/reflex/tests/reflex.scheduling.test.ts index 1868a314..62918416 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -76,7 +76,9 @@ 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("flush runs dirty nodes even when extra state bits are present", () => { From 896e678a1df8b5bca68d00e1196ae1eedae0c524 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Fri, 10 Apr 2026 15:45:35 +0300 Subject: [PATCH 07/14] fix propagate mistake --- packages/@reflex/runtime/AGENTS.md | 283 +++++++++++++ .../src/reactivity/walkers/propagate.once.ts | 56 +-- .../src/reactivity/walkers/propagate.ts | 52 +-- .../reactivity/walkers/recompute.branch.ts | 4 +- .../runtime/tests/runtime.walkers.test.ts | 24 ++ packages/reflex/README.md | 9 +- packages/reflex/bench/reflex.bench.ts | 2 +- packages/reflex/src/api/effect.ts | 2 + packages/reflex/src/infra/runtime.ts | 9 +- .../reflex/src/policy/SCHEDULER_SEMANTICS.md | 389 ++++++++++++++++++ .../reflex/src/policy/effect_scheduler.ts | 33 +- packages/reflex/tests/reflex.effects.test.ts | 20 + packages/reflex/tests/reflex.policy.test.ts | 14 + .../reflex/tests/reflex.scheduling.test.ts | 41 ++ 14 files changed, 860 insertions(+), 78 deletions(-) create mode 100644 packages/@reflex/runtime/AGENTS.md create mode 100644 packages/reflex/src/policy/SCHEDULER_SEMANTICS.md diff --git a/packages/@reflex/runtime/AGENTS.md b/packages/@reflex/runtime/AGENTS.md new file mode 100644 index 00000000..d5e9c778 --- /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/src/reactivity/walkers/propagate.once.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts index ae63955e..ff05fea7 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -14,52 +14,30 @@ export function propagateOnce(node: ReactiveNode): void { let thrown: unknown = null; const dispatch = dispatchEffectInvalidated; - if (dispatch === undefined) { - for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { - const sub = edge.to; - const state = sub.state; + for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { + const sub = edge.to; + const state = sub.state; - if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; + if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; - const nextState = state ^ DIRTY_STATE; - sub.state = nextState; + const nextState = state ^ DIRTY_STATE; + sub.state = nextState; - if (__DEV__) { - recordDebugEvent(defaultContext, "propagate", { - detail: { immediate: true, nextState }, - source: edge.from, - target: sub, - }); - - if ((nextState & WATCHER_MASK) !== 0) { - recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); - } - } + if (__DEV__) { + recordDebugEvent(defaultContext, "propagate", { + detail: { immediate: true, nextState }, + source: edge.from, + target: sub, + }); } - } else { - for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { - const sub = edge.to; - const state = sub.state; - if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; + if ((nextState & WATCHER_MASK) === 0) continue; - const nextState = state ^ DIRTY_STATE; - sub.state = nextState; - - if (__DEV__) { - recordDebugEvent(defaultContext, "propagate", { - detail: { immediate: true, nextState }, - source: edge.from, - target: sub, - }); - } - - if ((nextState & WATCHER_MASK) === 0) continue; - - if (__DEV__) { - recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); - } + if (__DEV__) { + recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); + } + if (dispatch !== undefined) { try { dispatch(sub); } catch (error) { diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index 60f89a99..0f8004f6 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -22,6 +22,26 @@ import { const propagateEdgeStack: ReactiveEdge[] = []; const propagatePromoteStack: number[] = []; +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 getSlowInvalidatedSubscriberState( edge: ReactiveEdge, sub: ReactiveNode, @@ -93,19 +113,7 @@ function propagateBranching( }); } - if ((nextState & WATCHER_MASK) !== 0) { - if (dispatch !== undefined) { - try { - dispatch(sub); - } catch (error) { - if (thrown === null) { - thrown = error; - } - } - } else if (__DEV__) { - recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); - } - } else { + if ((nextState & WATCHER_MASK) === 0) { const firstOut = sub.firstOut; if (firstOut !== null) { if (resume !== null) { @@ -118,6 +126,8 @@ function propagateBranching( promote = resumePromote = NON_IMMEDIATE; continue; } + } else { + thrown = dispatchInvalidatedWatcher(sub, dispatch, thrown); } } @@ -168,19 +178,7 @@ function propagateLinear( }); } - if ((nextState & WATCHER_MASK) !== 0) { - if (dispatch !== undefined) { - try { - dispatch(sub); - } catch (error) { - if (thrown === null) { - thrown = error; - } - } - } else if (__DEV__) { - recordDebugEvent(defaultContext, "watcher:invalidated", { node: sub }); - } - } else { + if ((nextState & WATCHER_MASK) === 0) { const firstOut = sub.firstOut; if (firstOut !== null) { edge = firstOut; @@ -198,6 +196,8 @@ function propagateLinear( promote = NON_IMMEDIATE; continue; } + } else { + thrown = dispatchInvalidatedWatcher(sub, dispatch, thrown); } } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts index adc6ca53..872b8221 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts @@ -37,7 +37,7 @@ function refreshAndPropagateIfFanout( ): boolean { const changed = refreshRecompute(node); - if (fanout) { + if (changed && fanout) { propagateOnce(node); } @@ -50,7 +50,7 @@ function refreshAndPropagateIfNeeded( ): boolean { const changed = refreshRecompute(node); - if (changed || fanout) { + if (changed && fanout) { propagateOnce(node); } diff --git a/packages/@reflex/runtime/tests/runtime.walkers.test.ts b/packages/@reflex/runtime/tests/runtime.walkers.test.ts index 581599f6..9da48ba8 100644 --- a/packages/@reflex/runtime/tests/runtime.walkers.test.ts +++ b/packages/@reflex/runtime/tests/runtime.walkers.test.ts @@ -396,6 +396,30 @@ 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); diff --git a/packages/reflex/README.md b/packages/reflex/README.md index 2387062e..58a7c64c 100644 --- a/packages/reflex/README.md +++ b/packages/reflex/README.md @@ -165,6 +165,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 +179,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 +190,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: @@ -333,7 +334,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 +346,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 469d8f33..bc5fe03a 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/src/api/effect.ts b/packages/reflex/src/api/effect.ts index 39d72e71..6e73c75c 100644 --- a/packages/reflex/src/api/effect.ts +++ b/packages/reflex/src/api/effect.ts @@ -98,6 +98,8 @@ 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: "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. diff --git a/packages/reflex/src/infra/runtime.ts b/packages/reflex/src/infra/runtime.ts index b685e574..a965d8df 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -10,7 +10,7 @@ import { import { subscribeEvent } from "./event"; import { createSource } from "./factory"; -interface RuntimeOptions { +export interface RuntimeOptions { /** * Optional low-level runtime hooks forwarded to the execution context. * @@ -22,6 +22,8 @@ interface RuntimeOptions { * Controls when invalidated effects are executed. * * - `"flush"` queues reruns until `rt.flush()` is called. + * - `"sab"` keeps lazy enqueue semantics but stabilizes effects after the + * outermost `rt.batch()` exits. * - `"eager"` flushes reruns automatically. */ effectStrategy?: EffectStrategy; @@ -110,7 +112,8 @@ export interface Runtime { * 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. + * scheduled effects to observe the latest stable snapshot. In `"sab"` and + * `"eager"` it remains available as an explicit synchronization escape hatch. */ flush(): void; /** @@ -131,7 +134,7 @@ export interface Runtime { * * @param options - Optional runtime configuration: * - `effectStrategy` controls whether invalidated effects flush on - * `rt.flush()` or automatically. + * `rt.flush()`, stabilize after the outermost batch, or run automatically. * - `hooks` installs low-level runtime hooks that are composed with Reflex's * scheduler integration. * diff --git a/packages/reflex/src/policy/SCHEDULER_SEMANTICS.md b/packages/reflex/src/policy/SCHEDULER_SEMANTICS.md new file mode 100644 index 00000000..0d6cb7d0 --- /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 index 330060f2..d3316a4a 100644 --- a/packages/reflex/src/policy/effect_scheduler.ts +++ b/packages/reflex/src/policy/effect_scheduler.ts @@ -35,6 +35,7 @@ export function effectUnscheduled( export const enum EffectSchedulerMode { Flush = 0, Eager = 1, + SAB = 2, } export const enum SchedulerPhase { @@ -43,14 +44,16 @@ export const enum SchedulerPhase { Flushing = 2, } -export type EffectStrategy = "flush" | "eager"; +export type EffectStrategy = "flush" | "eager" | "sab"; export function resolveEffectSchedulerMode( strategy: EffectStrategy | undefined, ): EffectSchedulerMode { return strategy === "eager" ? EffectSchedulerMode.Eager - : EffectSchedulerMode.Flush; + : strategy === "sab" + ? EffectSchedulerMode.SAB + : EffectSchedulerMode.Flush; } export interface EffectScheduler { @@ -85,6 +88,7 @@ export function createEffectScheduler( const ring: ReactiveNode[] = []; const eager = mode === EffectSchedulerMode.Eager; + const sab = mode === EffectSchedulerMode.SAB; const getContext = context === undefined ? getDefaultContext : () => context; let batchDepth = 0; @@ -192,8 +196,31 @@ export function createEffectScheduler( } } + function leaveBatchSAB(): void { + if (--batchDepth !== 0) return; + if (phase === SchedulerPhase.Flushing) return; + + phase = SchedulerPhase.Idle; + + if (!hasPending()) { + return; + } + + const currentContext = getContext(); + if ( + currentContext.propagationDepth === 0 && + currentContext.activeComputed === null + ) { + flushQueue(); + } + } + const enqueue = eager ? enqueueEager : enqueueFlush; - const leaveBatch = eager ? leaveBatchEager : leaveBatchFlush; + const leaveBatch = eager + ? leaveBatchEager + : sab + ? leaveBatchSAB + : leaveBatchFlush; function batch(fn: () => T): T { enterBatch(); diff --git a/packages/reflex/tests/reflex.effects.test.ts b/packages/reflex/tests/reflex.effects.test.ts index df6bd0e3..9b213b19 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); diff --git a/packages/reflex/tests/reflex.policy.test.ts b/packages/reflex/tests/reflex.policy.test.ts index e515397f..e103c18f 100644 --- a/packages/reflex/tests/reflex.policy.test.ts +++ b/packages/reflex/tests/reflex.policy.test.ts @@ -35,6 +35,7 @@ describe("Reactive system - policy helpers", () => { EffectSchedulerMode.Flush, ); expect(resolveEffectSchedulerMode("flush")).toBe(EffectSchedulerMode.Flush); + expect(resolveEffectSchedulerMode("sab")).toBe(EffectSchedulerMode.SAB); expect(resolveEffectSchedulerMode("eager")).toBe(EffectSchedulerMode.Eager); }); @@ -75,6 +76,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(() => {}); diff --git a/packages/reflex/tests/reflex.scheduling.test.ts b/packages/reflex/tests/reflex.scheduling.test.ts index 62918416..1a3e1ebe 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -121,6 +121,47 @@ 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", () => { + mocks.getDefaultContext.mockReturnValue( + createContext({ propagationDepth: 1 }), + ); + + 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); + + mocks.getDefaultContext.mockReturnValue(createContext()); + 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 }), From 63f90ec75d9db044d0be88a109f6ccbb1008b27a Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Fri, 10 Apr 2026 17:03:53 +0300 Subject: [PATCH 08/14] added specialization for recompute paths --- packages/@reflex/runtime/src/api/read.ts | 36 +++++--------- packages/@reflex/runtime/src/index.ts | 2 + .../@reflex/runtime/src/reactivity/context.ts | 36 ++++++++++++-- .../runtime/src/reactivity/engine/tracking.ts | 21 +++----- .../runtime/src/reactivity/engine/watcher.ts | 15 +++--- .../src/reactivity/walkers/recompute.ts | 44 +++++++++++++++++ .../runtime/tests/runtime.connect.test.ts | 48 ++++++++++++++++++- 7 files changed, 151 insertions(+), 51 deletions(-) diff --git a/packages/@reflex/runtime/src/api/read.ts b/packages/@reflex/runtime/src/api/read.ts index e22d7656..804fd361 100644 --- a/packages/@reflex/runtime/src/api/read.ts +++ b/packages/@reflex/runtime/src/api/read.ts @@ -11,11 +11,11 @@ import { ReactiveNodeState, trackReadActive, DIRTY_STATE, - shouldRecompute, recompute, propagateOnce, clearDirtyState, isDisposedNode, + shouldRecomputeDirtyConsumer, } from "../reactivity"; import { defaultContext } from "../reactivity/context"; @@ -82,7 +82,7 @@ export function readProducer(node: ReactiveNode): T { // Register this read as a dependency if there's an active computation if (activeComputed !== null) { - trackReadActive(node, activeComputed, context); + trackReadActive(node, activeComputed); } if (__DEV__) { @@ -136,30 +136,18 @@ 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)) { + if (recompute(node) && node.firstOut !== null) { + propagateOnce(node); } + } else { + clearDirtyState(node); } return node.payload as T; @@ -188,7 +176,7 @@ export function readConsumerLazy(node: ReactiveNode): T { const activeComputed = context.activeComputed; if (activeComputed !== null) { - trackReadActive(node, activeComputed, context); + trackReadActive(node, activeComputed); } if (__DEV__) { diff --git a/packages/@reflex/runtime/src/index.ts b/packages/@reflex/runtime/src/index.ts index d26ad5a7..07a2c09c 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 { diff --git a/packages/@reflex/runtime/src/reactivity/context.ts b/packages/@reflex/runtime/src/reactivity/context.ts index a362405f..e005d509 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -1,4 +1,5 @@ -import type { ReactiveNode } from "./shape"; +import type { ReactiveEdge, ReactiveNode } from "./shape"; +import { reuseIncomingEdgeFromSuffixOrCreate } from "./shape/methods/connect"; import { recordDebugEvent } from "../debug"; export interface EngineHooks { @@ -7,6 +8,16 @@ export interface EngineHooks { } export type CleanupRegistrar = (cleanup: () => void) => void; +export type TrackReadFallback = ( + source: ReactiveNode, + consumer: ReactiveNode, + prev: ReactiveEdge | null, + nextExpected: ReactiveEdge | null, +) => ReactiveEdge; + +export interface ExecutionContextOptions { + trackReadFallback?: TrackReadFallback; +} type OnEffectInvalidatedHook = EngineHooks["onEffectInvalidated"]; type OnReactiveSettledHook = EngineHooks["onReactiveSettled"]; @@ -14,11 +25,14 @@ type OnReactiveSettledHook = EngineHooks["onReactiveSettled"]; const IS_DEV = typeof __DEV__ !== "undefined" && __DEV__; export let dispatchEffectInvalidated = undefined as OnEffectInvalidatedHook; +export let trackReadFallback: TrackReadFallback = + reuseIncomingEdgeFromSuffixOrCreate; export class ExecutionContext { activeComputed: ReactiveNode | null = null; propagationDepth = 0; cleanupRegistrar: CleanupRegistrar | null = null; + trackReadFallback: TrackReadFallback = reuseIncomingEdgeFromSuffixOrCreate; onEffectInvalidated: OnEffectInvalidatedHook = undefined; onReactiveSettled: OnReactiveSettledHook = undefined; @@ -29,8 +43,9 @@ export class ExecutionContext { effectInvalidatedDispatch: OnEffectInvalidatedHook = undefined; settledDispatch: OnReactiveSettledHook = undefined; - constructor(hooks: EngineHooks = {}) { + constructor(hooks: EngineHooks = {}, options: ExecutionContextOptions = {}) { this.setHooks(hooks); + this.setOptions(options); } dispatchWatcherEvent(node: ReactiveNode): void { @@ -67,6 +82,13 @@ export class ExecutionContext { this.cleanupRegistrar = null; } + setOptions(options: ExecutionContextOptions = {}): void { + this.trackReadFallback = trackReadFallback = + typeof options.trackReadFallback === "function" + ? options.trackReadFallback + : reuseIncomingEdgeFromSuffixOrCreate; + } + setHooks(hooks: EngineHooks = {}): void { this.onEffectInvalidated = typeof hooks.onEffectInvalidated === "function" @@ -142,8 +164,9 @@ export let defaultContext = new ExecutionContext(); export function createExecutionContext( hooks: EngineHooks = {}, + options: ExecutionContextOptions = {}, ): ExecutionContext { - return new ExecutionContext(hooks); + return new ExecutionContext(hooks, options); } export function getDefaultContext(): ExecutionContext { @@ -156,6 +179,9 @@ export function setDefaultContext(context: ExecutionContext): ExecutionContext { return previous; } -export function resetDefaultContext(hooks: EngineHooks = {}): ExecutionContext { - return (defaultContext = new ExecutionContext(hooks)); +export function resetDefaultContext( + hooks: EngineHooks = {}, + options: ExecutionContextOptions = {}, +): ExecutionContext { + return (defaultContext = new ExecutionContext(hooks, options)); } diff --git a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts index f8b506f8..334cd17e 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -7,30 +7,26 @@ 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); + trackReadActive(source, consumer); } export function trackReadActive( source: ReactiveNode, consumer: ReactiveNode, - context = defaultContext, ): void { const sourceDead = isDisposedNode(source); const consumerDead = isDisposedNode(consumer); @@ -43,7 +39,7 @@ export function trackReadActive( } if (__DEV__) { - devRecordTrackRead(context, consumer, source); + devRecordTrackRead(defaultContext, consumer, source); } const prevEdge = consumer.depsTail; @@ -65,12 +61,7 @@ export function trackReadActive( return; } - consumer.depsTail = reuseIncomingEdgeFromSuffixOrCreate( - source, - consumer, - null, - firstIn, - ); + consumer.depsTail = trackReadFallback(source, consumer, null, firstIn); return; } @@ -92,7 +83,7 @@ export function trackReadActive( return; } - consumer.depsTail = reuseIncomingEdgeFromSuffixOrCreate( + consumer.depsTail = trackReadFallback( source, consumer, prevEdge, diff --git a/packages/@reflex/runtime/src/reactivity/engine/watcher.ts b/packages/@reflex/runtime/src/reactivity/engine/watcher.ts index b15a0c64..1aaec7eb 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/watcher.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/watcher.ts @@ -1,5 +1,7 @@ import { recordDebugEvent } from "../../debug"; -import { shouldRecompute } from "../walkers/recompute"; +import { + shouldRecomputeDirtyWatcher, +} from "../walkers/recompute"; import type { ReactiveNode } from "../shape"; import { clearNodeVisited, @@ -72,7 +74,7 @@ export function runWatcher(node: ReactiveNode): void { return; } - if (!shouldRecompute(node)) { + if (!shouldRecomputeDirtyWatcher(node, state)) { clearDirtyState(node); recordWatcherSkip(node, "stable"); return; @@ -94,11 +96,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; } @@ -109,7 +110,9 @@ export function runWatcher(node: ReactiveNode): void { (node.state & ~ReactiveNodeState.Changed) | ReactiveNodeState.Invalid; } - if (__DEV__) recordWatcherFinish(node, hasCleanup, result); + if (__DEV__) { + recordWatcherFinish(node, hasCleanup, result); + } } export function disposeWatcher(node: ReactiveNode): void { diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.ts index 36068eb5..24cc9977 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/tests/runtime.connect.test.ts b/packages/@reflex/runtime/tests/runtime.connect.test.ts index a4377aa0..c059f8a9 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); + }); }); From d7ba9ec706ca9c2ae8e9eedd7c0590c4a9a3e538 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Fri, 10 Apr 2026 23:44:21 +0300 Subject: [PATCH 09/14] scheduler refactoring --- packages/reflex/src/api/derived.ts | 2 +- packages/reflex/src/api/effect.ts | 4 +- packages/reflex/src/api/signal.ts | 9 +- packages/reflex/src/infra/runtime.ts | 75 +++-- .../reflex/src/policy/effect_scheduler.ts | 302 ------------------ packages/reflex/src/policy/scheduler/index.ts | 5 + .../policy/scheduler/scheduler.constants.ts | 17 + .../src/policy/scheduler/scheduler.core.ts | 162 ++++++++++ .../src/policy/scheduler/scheduler.queue.ts | 74 +++++ .../src/policy/scheduler/scheduler.types.ts | 54 ++++ .../src/policy/scheduler/variants/index.ts | 3 + .../scheduler/variants/scheduler.eager.ts | 48 +++ .../scheduler/variants/scheduler.flush.ts | 31 ++ .../scheduler/variants/scheduler.sab.ts | 40 +++ packages/reflex/tests/reflex.basic.test.ts | 5 +- .../reflex/tests/reflex.scheduling.test.ts | 14 +- .../reflex/tests/reflex.watcher_queue.test.ts | 111 +++++++ 17 files changed, 611 insertions(+), 345 deletions(-) delete mode 100644 packages/reflex/src/policy/effect_scheduler.ts create mode 100644 packages/reflex/src/policy/scheduler/index.ts create mode 100644 packages/reflex/src/policy/scheduler/scheduler.constants.ts create mode 100644 packages/reflex/src/policy/scheduler/scheduler.core.ts create mode 100644 packages/reflex/src/policy/scheduler/scheduler.queue.ts create mode 100644 packages/reflex/src/policy/scheduler/scheduler.types.ts create mode 100644 packages/reflex/src/policy/scheduler/variants/index.ts create mode 100644 packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts create mode 100644 packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts create mode 100644 packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts create mode 100644 packages/reflex/tests/reflex.watcher_queue.test.ts diff --git a/packages/reflex/src/api/derived.ts b/packages/reflex/src/api/derived.ts index 4ec690ed..95738d7b 100644 --- a/packages/reflex/src/api/derived.ts +++ b/packages/reflex/src/api/derived.ts @@ -85,6 +85,6 @@ export function computed(fn: () => T): Accessor { */ export function memo(fn: () => T): Accessor { const node = createComputedNode(fn); - readConsumerEager.call(null, node); + readConsumerEager(node); return readConsumerLazy.bind(null, node) as Accessor; } diff --git a/packages/reflex/src/api/effect.ts b/packages/reflex/src/api/effect.ts index 6e73c75c..717949a8 100644 --- a/packages/reflex/src/api/effect.ts +++ b/packages/reflex/src/api/effect.ts @@ -110,11 +110,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/signal.ts b/packages/reflex/src/api/signal.ts index 30580752..f38f9515 100644 --- a/packages/reflex/src/api/signal.ts +++ b/packages/reflex/src/api/signal.ts @@ -52,14 +52,13 @@ import { createSignalNode } from "../infra"; export function signal(initialValue: T): readonly [Accessor, 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 [ diff --git a/packages/reflex/src/infra/runtime.ts b/packages/reflex/src/infra/runtime.ts index a965d8df..de99acb3 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -1,14 +1,47 @@ -import { createExecutionContext, setDefaultContext } from "@reflex/runtime"; -import type { ExecutionContext, EngineHooks } from "@reflex/runtime"; -import type { EffectStrategy } from "../policy"; import { - createEffectScheduler, - EffectSchedulerMode, - EventDispatcher, - resolveEffectSchedulerMode, -} from "../policy"; + createExecutionContext, + getDefaultContext, + setDefaultContext, +} from "@reflex/runtime"; +import type { ExecutionContext, EngineHooks } from "@reflex/runtime"; import { subscribeEvent } from "./event"; import { createSource } from "./factory"; +import { EventDispatcher } from "../policy"; +import type { EffectScheduler } from "../policy/scheduler"; +import { + EffectSchedulerMode, + createEagerScheduler, + createSabScheduler, + createFlushScheduler, +} from "../policy/scheduler"; + +export type EffectStrategy = "flush" | "eager" | "sab"; + +const strategyMap: Record = { + eager: EffectSchedulerMode.Eager, + sab: EffectSchedulerMode.SAB, + flush: EffectSchedulerMode.Flush, +}; + +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.SAB: + return createSabScheduler(context); + default: + return createFlushScheduler(context); + } +} export interface RuntimeOptions { /** @@ -25,25 +58,23 @@ export interface RuntimeOptions { * - `"sab"` keeps lazy enqueue semantics but stabilizes effects after the * outermost `rt.batch()` exits. * - `"eager"` flushes reruns automatically. + * + * @default "flush" */ 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(); @@ -172,13 +203,8 @@ export function createRuntime(options?: RuntimeOptions): Runtime { const { scheduler, dispatcher, executionContext } = createRuntimeInfrastructure(options); return { - get ctx() { - return executionContext; - }, - - batch(fn) { - return scheduler.batch(fn); - }, + ctx: executionContext, + batch: scheduler.batch, event() { const source = createSource(); @@ -192,9 +218,6 @@ export function createRuntime(options?: RuntimeOptions): Runtime { }, }; }, - - flush() { - scheduler.flush(); - }, + flush: scheduler.flush, }; } diff --git a/packages/reflex/src/policy/effect_scheduler.ts b/packages/reflex/src/policy/effect_scheduler.ts deleted file mode 100644 index d3316a4a..00000000 --- a/packages/reflex/src/policy/effect_scheduler.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { - ReactiveNodeState, - runWatcher, - getDefaultContext, -} from "@reflex/runtime"; -import type { ExecutionContext, ReactiveNode } from "@reflex/runtime"; -import type { UNINITIALIZED } from "../infra/factory"; - -/** - * 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: ReactiveNode, -) { - const s = node.state; - node.state = s | 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: ReactiveNode, -) { - const s = node.state; - node.state = s & ~ReactiveNodeState.Scheduled; -} - -export const enum EffectSchedulerMode { - Flush = 0, - Eager = 1, - SAB = 2, -} - -export const enum SchedulerPhase { - Idle = 0, - Batching = 1, - Flushing = 2, -} - -export type EffectStrategy = "flush" | "eager" | "sab"; - -export function resolveEffectSchedulerMode( - strategy: EffectStrategy | undefined, -): EffectSchedulerMode { - return strategy === "eager" - ? EffectSchedulerMode.Eager - : strategy === "sab" - ? EffectSchedulerMode.SAB - : EffectSchedulerMode.Flush; -} - -export interface EffectScheduler { - readonly ring: 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 {} - -const SCHEDULED_OR_DISPOSED = - ReactiveNodeState.Disposed | ReactiveNodeState.Scheduled; -const INITIAL_QUEUE_CAPACITY = 16; - -export function createEffectScheduler( - mode: EffectSchedulerMode = EffectSchedulerMode.Flush, - context?: ExecutionContext, -): EffectScheduler { - let head = 0; - let tail = 0; - let size = 0; - - const ring: ReactiveNode[] = []; - const eager = mode === EffectSchedulerMode.Eager; - const sab = mode === EffectSchedulerMode.SAB; - const getContext = context === undefined ? getDefaultContext : () => context; - - let batchDepth = 0; - let phase = SchedulerPhase.Idle; - - function hasPending(): boolean { - return size !== 0; - } - - function growQueue(): void { - const capacity = ring.length; - const nextCapacity = - capacity === 0 ? INITIAL_QUEUE_CAPACITY : capacity << 1; - - const nextQueue = new Array< - ReactiveNode - >(nextCapacity); - - for (let i = 0; i < size; ++i) { - nextQueue[i] = ring[(head + i) % capacity]!; - } - - ring.length = nextCapacity; - for (let i = 0; i < size; ++i) { - ring[i] = nextQueue[i]!; - } - - head = 0; - tail = size; - } - - function push(node: ReactiveNode): void { - if (size === ring.length) { - growQueue(); - } - - ring[tail] = node; - tail = (tail + 1) % ring.length; - ++size; - } - - function shift(): ReactiveNode | null { - if (size === 0) { - return null; - } - - const node = ring[head]!; - ring[head] = undefined as never; - head = (head + 1) % ring.length; - --size; - return node; - } - - function enqueueFlush(node: ReactiveNode): void { - const state = node.state; - if ((state & SCHEDULED_OR_DISPOSED) !== 0) { - return; - } - - effectScheduled(node); - push(node); - } - - function enqueueEager(node: ReactiveNode): void { - const state = node.state; - if ((state & SCHEDULED_OR_DISPOSED) !== 0) { - return; - } - - effectScheduled(node); - 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 (hasPending()) { - flushQueue(); - } - } - - function leaveBatchSAB(): void { - if (--batchDepth !== 0) return; - if (phase === SchedulerPhase.Flushing) return; - - phase = SchedulerPhase.Idle; - - if (!hasPending()) { - return; - } - - const currentContext = getContext(); - if ( - currentContext.propagationDepth === 0 && - currentContext.activeComputed === null - ) { - flushQueue(); - } - } - - const enqueue = eager ? enqueueEager : enqueueFlush; - const leaveBatch = eager - ? leaveBatchEager - : sab - ? leaveBatchSAB - : leaveBatchFlush; - - function batch(fn: () => T): T { - enterBatch(); - try { - return fn(); - } finally { - leaveBatch(); - } - } - - function flushQueue(): void { - if (phase === SchedulerPhase.Flushing) return; - if (!hasPending()) return; - - phase = SchedulerPhase.Flushing; - - try { - while (size !== 0) { - const node = shift()!; - effectUnscheduled(node); - // if ( - // (state & ReactiveNodeState.Disposed) === 0 && - // (state & DIRTY_STATE) !== 0 - // ) must be there but already guaranties by runWatcher {} - runWatcher(node); - } - } finally { - head = tail = size = 0; - phase = batchDepth > 0 ? SchedulerPhase.Batching : SchedulerPhase.Idle; - } - } - - function notifySettledEager(): void { - const currentContext = getContext(); - const inactive = - phase === SchedulerPhase.Idle && - batchDepth === 0 && - currentContext.propagationDepth === 0 && - currentContext.activeComputed === null; - - if (inactive && hasPending()) { - flushQueue(); - } - } - - const flush = flushQueue; - const notifySettled = eager ? notifySettledEager : noopNotifySettled; - - function reset(): void { - while (size !== 0) { - shift()!.state &= ~ReactiveNodeState.Scheduled; - } - - head = tail = size = batchDepth = 0; - phase = SchedulerPhase.Idle; - } - - return { - ring, - 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/scheduler/index.ts b/packages/reflex/src/policy/scheduler/index.ts new file mode 100644 index 00000000..d960a1cd --- /dev/null +++ b/packages/reflex/src/policy/scheduler/index.ts @@ -0,0 +1,5 @@ +export * from "./variants"; +export * from "./scheduler.constants"; +export * from "./scheduler.core"; +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 00000000..035f38d2 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.constants.ts @@ -0,0 +1,17 @@ +import { ReactiveNodeState } from "@reflex/runtime"; + +export const enum EffectSchedulerMode { + Flush = 0, + Eager = 1, + SAB = 2, +} + +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 00000000..db65ae57 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.core.ts @@ -0,0 +1,162 @@ +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"; + +/** + * 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()!; + node.state &= UNSCHEDULE_MASK; + runWatcher(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.queue.ts b/packages/reflex/src/policy/scheduler/scheduler.queue.ts new file mode 100644 index 00000000..4eb90cdb --- /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 00000000..22f623c2 --- /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 00000000..e10a7180 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/index.ts @@ -0,0 +1,3 @@ +export * from "./scheduler.eager"; +export * from "./scheduler.flush"; +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 00000000..0f67238f --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts @@ -0,0 +1,48 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { EffectSchedulerMode } from "../scheduler.constants"; +import { + createSchedulerCore, + isRuntimeInactive, + createSchedulerInstance, + tryEnqueue, +} from "../scheduler.core"; +import type { 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(); + } + }; + + return createSchedulerInstance( + EffectSchedulerMode.Eager, + context, + core, + (node) => { + if (!tryEnqueue(queue, node)) { + return; + } + + if (isRuntimeInactive(context, core)) { + core.flush(); + } + }, + (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + if (core.leaveBatch() && queue.size !== 0) { + core.flush(); + } + } + }, + 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 00000000..0eea5f43 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts @@ -0,0 +1,31 @@ +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; + + return createSchedulerInstance( + EffectSchedulerMode.Flush, + context, + core, + (node) => { + tryEnqueue(queue, node); + }, + (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + core.leaveBatch(); + } + }, + 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 00000000..b58c7fe8 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts @@ -0,0 +1,40 @@ +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; + + return createSchedulerInstance( + EffectSchedulerMode.SAB, + context, + core, + (node) => { + tryEnqueue(queue, node); + }, + (fn: () => T): T => { + core.enterBatch(); + try { + return fn(); + } finally { + if ( + core.leaveBatch() && + queue.size !== 0 && + isContextSettled(context) + ) { + core.flush(); + } + } + }, + noopNotifySettled, + undefined, + ); +} diff --git a/packages/reflex/tests/reflex.basic.test.ts b/packages/reflex/tests/reflex.basic.test.ts index 19395eb4..fd61b561 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.scheduling.test.ts b/packages/reflex/tests/reflex.scheduling.test.ts index 1a3e1ebe..ae397454 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -141,9 +141,8 @@ describe("createEffectScheduler", () => { }); it("keeps sab effects queued when batch exits during active propagation", () => { - mocks.getDefaultContext.mockReturnValue( - createContext({ propagationDepth: 1 }), - ); + const context = createContext({ propagationDepth: 1 }); + mocks.getDefaultContext.mockReturnValue(context); const scheduler = createEffectScheduler(EffectSchedulerMode.SAB); const node = createNode(); @@ -155,7 +154,7 @@ describe("createEffectScheduler", () => { expect(mocks.runWatcher).not.toHaveBeenCalled(); expect((node.state & ReactiveNodeState.Scheduled) !== 0).toBe(true); - mocks.getDefaultContext.mockReturnValue(createContext()); + context.propagationDepth = 0; scheduler.flush(); expect(mocks.runWatcher).toHaveBeenCalledTimes(1); @@ -163,9 +162,8 @@ describe("createEffectScheduler", () => { }); 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(); @@ -175,7 +173,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); 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 00000000..404ca223 --- /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/createWatcherQueue"; + +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(); + }); +}); From 99e4f2b2a546098d004cb810e9af3e5f901620d2 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Sat, 11 Apr 2026 18:03:13 +0300 Subject: [PATCH 10/14] before patch --- .../@reflex/runtime/rollup.perf.config.ts | 2 + packages/@reflex/runtime/src/index.ts | 6 + .../runtime/src/reactivity/engine/execute.ts | 26 +-- .../runtime/src/reactivity/engine/tracking.ts | 61 +++++- .../@reflex/runtime/src/reactivity/perf.ts | 37 ++++ .../src/reactivity/shape/methods/connect.ts | 10 + .../perf/duplicate-read-single-source.jit.mjs | 148 +++++++++++++ .../perf/repeated-read-branching.jit.mjs | 197 ++++++++++++++++++ packages/reflex/src/infra/factory.ts | 24 +-- packages/reflex/src/infra/runtime.ts | 42 +--- packages/reflex/src/policy/index.ts | 1 - packages/reflex/src/policy/scheduler/index.ts | 1 + .../src/policy/scheduler/scheduler.core.ts | 9 +- .../src/policy/scheduler/scheduler.infra.ts | 37 ++++ .../scheduler/variants/scheduler.eager.ts | 38 ++-- .../scheduler/variants/scheduler.flush.ts | 32 +-- .../scheduler/variants/scheduler.sab.ts | 31 ++- packages/reflex/tests/reflex.exports.test.ts | 5 +- packages/reflex/tests/reflex.policy.test.ts | 35 ++-- .../reflex/tests/reflex.scheduling.test.ts | 2 +- .../reflex/tests/reflex.watcher_queue.test.ts | 2 +- 21 files changed, 604 insertions(+), 142 deletions(-) create mode 100644 packages/@reflex/runtime/src/reactivity/perf.ts create mode 100644 packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs create mode 100644 packages/@reflex/runtime/tests/perf/repeated-read-branching.jit.mjs create mode 100644 packages/reflex/src/policy/scheduler/scheduler.infra.ts diff --git a/packages/@reflex/runtime/rollup.perf.config.ts b/packages/@reflex/runtime/rollup.perf.config.ts index f08a3b8e..9afd306d 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/index.ts b/packages/@reflex/runtime/src/index.ts index 07a2c09c..caca6d47 100644 --- a/packages/@reflex/runtime/src/index.ts +++ b/packages/@reflex/runtime/src/index.ts @@ -26,6 +26,12 @@ export { type TrackReadFallback, } from "./reactivity/context"; +export { + createRuntimePerfCounters, + setRuntimePerfCounters, + type RuntimePerfCounters, +} from "./reactivity/perf"; + export { DIRTY_STATE, // diff --git a/packages/@reflex/runtime/src/reactivity/engine/execute.ts b/packages/@reflex/runtime/src/reactivity/engine/execute.ts index 62d93afd..1a0091ca 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/execute.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/execute.ts @@ -8,7 +8,7 @@ import { import { cleanupStaleSources } from "./tracking"; import { defaultContext } from "../context"; -function prepareNodeExecution(node: ReactiveNode): () => void { +function prepareNodeExecution(node: ReactiveNode): ReactiveNode | null { const context = defaultContext; node.depsTail = null; @@ -18,7 +18,6 @@ function prepareNodeExecution(node: ReactiveNode): () => void { const prevActive = context.activeComputed; context.activeComputed = node; - let restored = false; if (__DEV__) { recordDebugEvent(context, "compute:start", { @@ -26,13 +25,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 +50,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 +72,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 334cd17e..96b31fbb 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -10,6 +10,7 @@ import { unlinkDetachedIncomingEdgeSequence, } from "../shape/methods/connect"; import { defaultContext, trackReadFallback } from "../context"; +import { runtimePerfCounters } from "../perf"; /** * Cursor-guided incoming-edge walk used during dependency collection. @@ -28,9 +29,19 @@ export function trackReadActive( source: ReactiveNode, consumer: ReactiveNode, ): void { + const perf = runtimePerfCounters; + if (perf !== null) { + perf.trackReadCalls += 1; + perf.trackReadWhileActive += 1; + } + const sourceDead = isDisposedNode(source); const consumerDead = isDisposedNode(consumer); if (sourceDead || consumerDead) { + if (perf !== null) { + perf.trackReadDisposedSkip += 1; + } + if (__DEV__) { devAssertTrackReadAlive(sourceDead, consumerDead); } @@ -47,6 +58,9 @@ export function trackReadActive( const firstIn = consumer.firstIn; if (firstIn === null) { + if (perf !== null) { + perf.trackReadNewEdge += 1; + } consumer.depsTail = linkEdge(source, consumer, null); return; } @@ -57,32 +71,55 @@ export function trackReadActive( } if (firstIn.nextIn === null) { + if (perf !== null) { + perf.trackReadNewEdge += 1; + } consumer.depsTail = linkEdge(source, consumer, null); return; } + if (perf !== null) { + perf.trackReadFallbackScan += 1; + } consumer.depsTail = trackReadFallback(source, consumer, null, firstIn); return; } - if (prevEdge.from === source) return; + if (prevEdge.from === source) { + if (perf !== null) { + perf.trackReadDuplicateSourceHit += 1; + } + return; + } const nextExpected = prevEdge.nextIn; if (nextExpected === null) { + if (perf !== null) { + perf.trackReadNewEdge += 1; + } consumer.depsTail = linkEdge(source, consumer, prevEdge); return; } if (nextExpected.from === source) { + if (perf !== null) { + perf.trackReadExpectedEdgeHit += 1; + } consumer.depsTail = nextExpected; return; } if (nextExpected.nextIn === null) { + if (perf !== null) { + perf.trackReadNewEdge += 1; + } consumer.depsTail = linkEdge(source, consumer, prevEdge); return; } + if (perf !== null) { + perf.trackReadFallbackScan += 1; + } consumer.depsTail = trackReadFallback( source, consumer, @@ -96,9 +133,15 @@ export function trackReadActive( * Everything after depsTail belongs to the old dependency list and is unlinked. */ export function cleanupStaleSources(node: ReactiveNode): void { + const perf = runtimePerfCounters; + if (perf !== null) { + perf.cleanupPassCount += 1; + } + 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; @@ -109,8 +152,20 @@ export function cleanupStaleSources(node: ReactiveNode): void { } if (__DEV__) { - devRecordCleanupStaleSources(node, staleHead, defaultContext); + devRecordCleanupStaleSources(node, detachedStaleHead, defaultContext); + } + + if (perf !== null) { + let removedCount = 0; + for ( + let edge: typeof detachedStaleHead | null = detachedStaleHead; + edge !== null; + edge = edge.nextIn + ) { + removedCount += 1; + } + perf.cleanupStaleEdgeCount += removedCount; } - unlinkDetachedIncomingEdgeSequence(staleHead); + unlinkDetachedIncomingEdgeSequence(detachedStaleHead); } diff --git a/packages/@reflex/runtime/src/reactivity/perf.ts b/packages/@reflex/runtime/src/reactivity/perf.ts new file mode 100644 index 00000000..16952f1f --- /dev/null +++ b/packages/@reflex/runtime/src/reactivity/perf.ts @@ -0,0 +1,37 @@ +export interface RuntimePerfCounters { + cleanupPassCount: number; + cleanupStaleEdgeCount: number; + trackReadCalls: number; + trackReadDisposedSkip: number; + trackReadDuplicateSourceHit: number; + trackReadExpectedEdgeHit: number; + trackReadFallbackScan: number; + trackReadNewEdge: number; + trackReadReorder: number; + trackReadWhileActive: number; +} + +export let runtimePerfCounters: RuntimePerfCounters | null = null; + +export function createRuntimePerfCounters(): RuntimePerfCounters { + return { + cleanupPassCount: 0, + cleanupStaleEdgeCount: 0, + trackReadCalls: 0, + trackReadDisposedSkip: 0, + trackReadDuplicateSourceHit: 0, + trackReadExpectedEdgeHit: 0, + trackReadFallbackScan: 0, + trackReadNewEdge: 0, + trackReadReorder: 0, + trackReadWhileActive: 0, + }; +} + +export function setRuntimePerfCounters( + counters: RuntimePerfCounters | null, +): RuntimePerfCounters | null { + const previous = runtimePerfCounters; + runtimePerfCounters = counters; + return previous; +} diff --git a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts index ad402aed..8d1987bf 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts @@ -2,6 +2,7 @@ import { clearReactiveEdgeLinks, ReactiveEdge } from "../ReactiveEdge"; import { isDisposedNode, markDisposedNode } from "../ReactiveMeta"; import type ReactiveNode from "../ReactiveNode"; import { UNINITIALIZED } from "../ReactiveNode"; +import { runtimePerfCounters } from "../../perf"; // ─── Internal helpers ──────────────────────────────────────────────────────── @@ -84,6 +85,8 @@ export function reuseIncomingEdgeFromSuffixOrCreate( prev: ReactiveEdge | null, nextExpected: ReactiveEdge | null, ): ReactiveEdge { + const perf = runtimePerfCounters; + // Scan the rest of the incoming list for a reusable edge. for ( let edge = nextExpected ? nextExpected.nextIn : to.firstIn; @@ -94,6 +97,9 @@ export function reuseIncomingEdgeFromSuffixOrCreate( // Found one — reposition it if it's out of order. if (edge.prevIn !== prev) { + if (perf !== null) { + perf.trackReadReorder += 1; + } detachInEdge(to, edge); attachInEdge(to, edge, prev); } @@ -101,6 +107,10 @@ export function reuseIncomingEdgeFromSuffixOrCreate( return edge; } + if (perf !== null) { + perf.trackReadNewEdge += 1; + } + return linkEdge(from, to, prev); } diff --git a/packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs b/packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs new file mode 100644 index 00000000..99c46a09 --- /dev/null +++ b/packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs @@ -0,0 +1,148 @@ +import { performance } from "node:perf_hooks"; +import { + CONSUMER_INITIAL_STATE, + PRODUCER_INITIAL_STATE, + ReactiveNode, + WATCHER_INITIAL_STATE, + createRuntimePerfCounters, + readConsumer, + readProducer, + resetDefaultContext, + runWatcher, + setRuntimePerfCounters, + 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 createScenario(kind) { + resetDefaultContext(); + + const counters = createRuntimePerfCounters(); + setRuntimePerfCounters(counters); + + const head = createProducer(0); + const current = createConsumer(() => { + let result = 0; + + if (kind === "repeated") { + for (let i = 0; i < 20; i += 1) { + result += readProducer(head); + } + return result; + } + + const value = readProducer(head); + for (let i = 0; i < 20; i += 1) { + result += value; + } + + return result; + }); + const effect = createWatcher(() => readConsumer(current)); + + runWatcher(effect); + + return { + counters, + run(iteration) { + writeProducer(head, iteration); + runWatcher(effect); + return readConsumer(current); + }, + }; +} + +function printCounters(label, counters, iterations) { + console.log(`${label}:`); + console.log( + JSON.stringify( + { + ...counters, + trackReadCallsPerUniqueDep: counters.trackReadCalls, + trackReadCallsPerWrite: counters.trackReadCalls / iterations, + }, + null, + 2, + ), + ); +} + +function runProfile(label, kind, iterations) { + const scenario = createScenario(kind); + + for (let i = 0; i < iterations; i += 1) { + scenario.run(i); + } + + printCounters(label, scenario.counters, iterations); +} + +function runSuite() { + const iterations = 30000; + const warmup = 5000; + + bench( + "duplicate_read_single_source", + (() => { + const scenario = createScenario("repeated"); + return (i) => scenario.run(i); + })(), + iterations, + warmup, + ); + + bench( + "duplicate_read_single_source_cached", + (() => { + const scenario = createScenario("cached"); + return (i) => scenario.run(i); + })(), + iterations, + warmup, + ); + + runProfile("duplicate_read_single_source_profile", "repeated", 100); + runProfile("duplicate_read_single_source_cached_profile", "cached", 100); + setRuntimePerfCounters(null); +} + +runSuite(); 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 00000000..660269ab --- /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/src/infra/factory.ts b/packages/reflex/src/infra/factory.ts index 76187ad2..b2e02017 100644 --- a/packages/reflex/src/infra/factory.ts +++ b/packages/reflex/src/infra/factory.ts @@ -9,38 +9,38 @@ import { EventSource as RuntimeEventSource } from "./event"; export const UNINITIALIZED = Symbol("UNINITIALIZED") as unknown; -export function createSignalNode(payload: T) { +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() { +export const createResourceStateNode = () => { return new RuntimeReactiveNode( 0, /*TODO: replace with undefined*/ null, PRODUCER_INITIAL_STATE, ); -} +}; -export function createAccumulator(payload: T): ReactiveNode { +export const createAccumulator = (payload: T): ReactiveNode => { return new RuntimeReactiveNode( payload, /*TODO: replace with undefined*/ null, PRODUCER_INITIAL_STATE, ); -} +}; -export function createComputedNode(fn: () => T) { +export const createComputedNode = (fn: () => T) => { return new RuntimeReactiveNode(undefined as T, fn, CONSUMER_INITIAL_STATE); -} +}; -export function createWatcherNode(compute: EffectFn): ReactiveNode { +export const createWatcherNode = (compute: EffectFn): ReactiveNode => { return new RuntimeReactiveNode(undefined, compute, WATCHER_INITIAL_STATE); -} +}; diff --git a/packages/reflex/src/infra/runtime.ts b/packages/reflex/src/infra/runtime.ts index de99acb3..0ed6e4c7 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -1,48 +1,14 @@ -import { - createExecutionContext, - getDefaultContext, - setDefaultContext, -} from "@reflex/runtime"; +import { createExecutionContext, setDefaultContext } from "@reflex/runtime"; import type { ExecutionContext, EngineHooks } from "@reflex/runtime"; import { subscribeEvent } from "./event"; import { createSource } from "./factory"; import { EventDispatcher } from "../policy"; -import type { EffectScheduler } from "../policy/scheduler"; +import type { EffectStrategy } from "../policy/scheduler"; import { - EffectSchedulerMode, - createEagerScheduler, - createSabScheduler, - createFlushScheduler, + createEffectScheduler, + resolveEffectSchedulerMode, } from "../policy/scheduler"; -export type EffectStrategy = "flush" | "eager" | "sab"; - -const strategyMap: Record = { - eager: EffectSchedulerMode.Eager, - sab: EffectSchedulerMode.SAB, - flush: EffectSchedulerMode.Flush, -}; - -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.SAB: - return createSabScheduler(context); - default: - return createFlushScheduler(context); - } -} - export interface RuntimeOptions { /** * Optional low-level runtime hooks forwarded to the execution context. diff --git a/packages/reflex/src/policy/index.ts b/packages/reflex/src/policy/index.ts index 382c4a68..5ecf9314 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 index d960a1cd..093915a9 100644 --- a/packages/reflex/src/policy/scheduler/index.ts +++ b/packages/reflex/src/policy/scheduler/index.ts @@ -1,5 +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.core.ts b/packages/reflex/src/policy/scheduler/scheduler.core.ts index db65ae57..6827ac80 100644 --- a/packages/reflex/src/policy/scheduler/scheduler.core.ts +++ b/packages/reflex/src/policy/scheduler/scheduler.core.ts @@ -18,6 +18,8 @@ import type { WatcherQueue, } from "./scheduler.types"; +const runner = runWatcher.bind(null); + /** * Marks an effect watcher node as scheduled. * @@ -66,9 +68,10 @@ export function createSchedulerCore(): SchedulerCore { try { while (queue.size !== 0) { - const node = queue.shift()!; - node.state &= UNSCHEDULE_MASK; - runWatcher(node); + const node = queue.shift()!, + s = node.state; + node.state = s & UNSCHEDULE_MASK; + runner(node); } } finally { queue.clear(); 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 00000000..81684a35 --- /dev/null +++ b/packages/reflex/src/policy/scheduler/scheduler.infra.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from "@reflex/runtime"; +import { getDefaultContext } from "@reflex/runtime"; +import { EffectSchedulerMode } from "./scheduler.constants"; +import type { EffectScheduler } from "./scheduler.types"; +import { + createEagerScheduler, + createSabScheduler, + createFlushScheduler, +} from "./variants"; + +export type EffectStrategy = "flush" | "eager" | "sab"; + +const strategyMap: Record = { + eager: EffectSchedulerMode.Eager, + sab: EffectSchedulerMode.SAB, + flush: EffectSchedulerMode.Flush, +}; + +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.SAB: + return createSabScheduler(context); + default: + return createFlushScheduler(context); + } +} diff --git a/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts b/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts index 0f67238f..664e4458 100644 --- a/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.eager.ts @@ -6,7 +6,7 @@ import { createSchedulerInstance, tryEnqueue, } from "../scheduler.core"; -import type { EffectScheduler } from "../scheduler.types"; +import type { EffectNode, EffectScheduler } from "../scheduler.types"; export function createEagerScheduler( context: ExecutionContext, @@ -18,30 +18,28 @@ export function createEagerScheduler( 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, - (node) => { - if (!tryEnqueue(queue, node)) { - return; - } - - if (isRuntimeInactive(context, core)) { - core.flush(); - } - }, - (fn: () => T): T => { - core.enterBatch(); - try { - return fn(); - } finally { - if (core.leaveBatch() && queue.size !== 0) { - core.flush(); - } - } - }, + 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 index 0eea5f43..e4650767 100644 --- a/packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.flush.ts @@ -1,7 +1,11 @@ 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 { + createSchedulerCore, + createSchedulerInstance, + tryEnqueue, +} from "../scheduler.core"; +import type { EffectScheduler } from "../scheduler.types"; import { noopNotifySettled } from "../scheduler.types"; export function createFlushScheduler( @@ -9,22 +13,22 @@ export function createFlushScheduler( ): 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, - (node) => { - tryEnqueue(queue, node); - }, - (fn: () => T): T => { - core.enterBatch(); - try { - return fn(); - } finally { - core.leaveBatch(); - } - }, + 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 index b58c7fe8..98b3e5e0 100644 --- a/packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts +++ b/packages/reflex/src/policy/scheduler/variants/scheduler.sab.ts @@ -12,28 +12,25 @@ 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, - (node) => { - tryEnqueue(queue, node); - }, - (fn: () => T): T => { - core.enterBatch(); - try { - return fn(); - } finally { - if ( - core.leaveBatch() && - queue.size !== 0 && - isContextSettled(context) - ) { - core.flush(); - } - } - }, + enqueue, + batch, noopNotifySettled, undefined, ); diff --git a/packages/reflex/tests/reflex.exports.test.ts b/packages/reflex/tests/reflex.exports.test.ts index 826caf6d..84418627 100644 --- a/packages/reflex/tests/reflex.exports.test.ts +++ b/packages/reflex/tests/reflex.exports.test.ts @@ -2,7 +2,8 @@ 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"; @@ -24,7 +25,7 @@ describe("Reactive system - exports", () => { 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.policy.test.ts b/packages/reflex/tests/reflex.policy.test.ts index e103c18f..0a13d83d 100644 --- a/packages/reflex/tests/reflex.policy.test.ts +++ b/packages/reflex/tests/reflex.policy.test.ts @@ -4,11 +4,9 @@ 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, @@ -130,20 +128,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 ae397454..435cb6b5 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -22,7 +22,7 @@ import { DIRTY_STATE, ReactiveNodeState } from "@reflex/runtime"; import { createEffectScheduler, EffectSchedulerMode, -} from "../src/policy/effect_scheduler"; +} from "../src/policy/scheduler"; function createContext( overrides: Partial = {}, diff --git a/packages/reflex/tests/reflex.watcher_queue.test.ts b/packages/reflex/tests/reflex.watcher_queue.test.ts index 404ca223..b9db31f1 100644 --- a/packages/reflex/tests/reflex.watcher_queue.test.ts +++ b/packages/reflex/tests/reflex.watcher_queue.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createWatcherNode } from "../src/infra/factory"; -import { createWatcherQueue } from "../src/policy/scheduler/createWatcherQueue"; +import { createWatcherQueue } from "../src/policy/scheduler"; function createNodes(count: number) { return Array.from({ length: count }, () => createWatcherNode(() => {})); From 35e4d91a43da234d1d446f3649d712db3d4386c8 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Sat, 11 Apr 2026 19:26:56 +0300 Subject: [PATCH 11/14] Implement fast path for dependency tracking and add tests for performance optimizations --- packages/@reflex/runtime/src/api/read.ts | 9 +- .../runtime/src/reactivity/engine/tracking.ts | 45 +++++++ .../tests/runtime.duplicate_path.test.ts | 110 ++++++++++++++++++ 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts diff --git a/packages/@reflex/runtime/src/api/read.ts b/packages/@reflex/runtime/src/api/read.ts index 804fd361..8c725c17 100644 --- a/packages/@reflex/runtime/src/api/read.ts +++ b/packages/@reflex/runtime/src/api/read.ts @@ -10,6 +10,7 @@ import { import { ReactiveNodeState, trackReadActive, + tryTrackReadFastPath, DIRTY_STATE, recompute, propagateOnce, @@ -82,7 +83,9 @@ export function readProducer(node: ReactiveNode): T { // Register this read as a dependency if there's an active computation if (activeComputed !== null) { - trackReadActive(node, activeComputed); + if (!tryTrackReadFastPath(node, activeComputed)) { + trackReadActive(node, activeComputed); + } } if (__DEV__) { @@ -176,7 +179,9 @@ export function readConsumerLazy(node: ReactiveNode): T { const activeComputed = context.activeComputed; if (activeComputed !== null) { - trackReadActive(node, activeComputed); + if (!tryTrackReadFastPath(node, activeComputed)) { + trackReadActive(node, activeComputed); + } } if (__DEV__) { diff --git a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts index 96b31fbb..1e806552 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -22,9 +22,54 @@ export function trackRead(source: ReactiveNode): void { const consumer = context.activeComputed; if (!consumer) return; + 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 perf = runtimePerfCounters; + + if (prevEdge !== null) { + if (prevEdge.from === source) { + if (perf !== null) { + perf.trackReadDuplicateSourceHit += 1; + } + return true; + } + + const nextExpected = prevEdge.nextIn; + if (nextExpected !== null && nextExpected.from === source) { + if (perf !== null) { + perf.trackReadExpectedEdgeHit += 1; + } + consumer.depsTail = nextExpected; + return true; + } + + return false; + } + + const firstIn = consumer.firstIn; + if (firstIn !== null && firstIn.from === source) { + consumer.depsTail = firstIn; + return true; + } + + return false; +} + export function trackReadActive( source: ReactiveNode, consumer: ReactiveNode, diff --git a/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts b/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts new file mode 100644 index 00000000..a518912e --- /dev/null +++ b/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createRuntimePerfCounters, + readConsumer, + readProducer, + setRuntimePerfCounters, + writeProducer, +} from "../src"; +import { + createConsumer, + createProducer, + incomingSources, + resetRuntime, +} from "./runtime.test_utils"; + +describe("Reactive runtime - cursor fast path", () => { + beforeEach(() => { + resetRuntime(); + }); + + afterEach(() => { + setRuntimePerfCounters(null); + }); + + it("makes consecutive duplicate reads structurally inert after the first link", () => { + const counters = createRuntimePerfCounters(); + setRuntimePerfCounters(counters); + + const head = createProducer(1); + const current = createConsumer( + () => readProducer(head) + readProducer(head) + readProducer(head), + ); + + expect(readConsumer(current)).toBe(3); + expect(incomingSources(current)).toEqual([head]); + expect(counters.trackReadCalls).toBe(1); + expect(counters.trackReadDuplicateSourceHit).toBeGreaterThanOrEqual(2); + + writeProducer(head, 2); + expect(readConsumer(current)).toBe(6); + expect(incomingSources(current)).toEqual([head]); + expect(counters.trackReadCalls).toBe(1); + }); + + it("reuses stable expected order across passes without re-entering trackReadActive", () => { + const counters = createRuntimePerfCounters(); + setRuntimePerfCounters(counters); + + const a = createProducer(1); + const b = createProducer(10); + const current = createConsumer(() => readProducer(a) + readProducer(b)); + + expect(readConsumer(current)).toBe(11); + expect(incomingSources(current)).toEqual([a, b]); + expect(counters.trackReadCalls).toBe(2); + + writeProducer(a, 2); + expect(readConsumer(current)).toBe(12); + expect(incomingSources(current)).toEqual([a, b]); + expect(counters.trackReadCalls).toBe(2); + expect(counters.trackReadExpectedEdgeHit).toBeGreaterThanOrEqual(1); + }); + + it("keeps branch flips correct across passes and prunes stale deps", () => { + const counters = createRuntimePerfCounters(); + setRuntimePerfCounters(counters); + + const flag = createProducer(true); + const left = createProducer(2); + const right = createProducer(7); + const current = createConsumer(() => + readProducer(flag) ? readProducer(left) : readProducer(right), + ); + + expect(readConsumer(current)).toBe(2); + expect(incomingSources(current)).toEqual([flag, left]); + + writeProducer(flag, false); + expect(readConsumer(current)).toBe(7); + expect(incomingSources(current)).toEqual([flag, right]); + expect(counters.cleanupPassCount).toBeGreaterThanOrEqual(1); + expect(counters.cleanupStaleEdgeCount).toBeGreaterThanOrEqual(1); + + writeProducer(left, 5); + expect(readConsumer(current)).toBe(7); + expect(incomingSources(current)).toEqual([flag, right]); + }); + + it("preserves savings across a deeper nested chain of duplicate reads", () => { + const counters = createRuntimePerfCounters(); + setRuntimePerfCounters(counters); + + const head = createProducer(1); + const chain = [createConsumer(() => readProducer(head) + readProducer(head))]; + + for (let i = 1; i < 6; i += 1) { + const prev = chain[i - 1]; + chain.push(createConsumer(() => readConsumer(prev) + readConsumer(prev))); + } + + const root = chain[chain.length - 1]; + + expect(readConsumer(root)).toBe(64); + expect(counters.trackReadCalls).toBe(6); + + writeProducer(head, 2); + expect(readConsumer(root)).toBe(128); + expect(counters.trackReadCalls).toBe(6); + }); +}); From ec4f3960a3f8e89298e116327508f1d91b91a8fe Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Sun, 12 Apr 2026 19:02:46 +0300 Subject: [PATCH 12/14] feat: implement ranked effect scheduling strategy - Introduced a new effect scheduling mode "ranked" to prioritize execution of nodes based on their assigned priority. - Updated the effect scheduler to handle the new mode, ensuring higher-priority nodes are executed first while maintaining FIFO order for nodes with the same priority. - Enhanced the runtime to support edge versioning, allowing non-adjacent duplicate reads to be inert within the same pass. - Added tests to verify the correct behavior of the ranked scheduling strategy and its integration with existing functionality. - Updated documentation to reflect changes in the effect strategy options and their behaviors. --- .../@reflex/runtime/src/reactivity/context.ts | 3 + .../runtime/src/reactivity/engine/execute.ts | 2 + .../runtime/src/reactivity/engine/tracking.ts | 39 +- .../src/reactivity/shape/ReactiveEdge.ts | 3 + .../src/reactivity/shape/methods/connect.ts | 22 +- .../src/reactivity/walkers/propagate.ts | 65 ++- .../perf/propagate-stack-compare.jit.mjs | 496 +++++++++++++++++- .../tests/runtime.duplicate_path.test.ts | 21 + .../runtime/tests/runtime.walkers.test.ts | 25 + packages/reflex/package.json | 2 +- packages/reflex/src/api/effect.ts | 2 + packages/reflex/src/infra/runtime.ts | 5 +- .../policy/scheduler/scheduler.constants.ts | 1 + .../src/policy/scheduler/scheduler.infra.ts | 6 +- .../src/policy/scheduler/variants/index.ts | 1 + .../scheduler/variants/scheduler.ranked.ts | 118 +++++ packages/reflex/tests/reflex.policy.test.ts | 3 + .../reflex/tests/reflex.scheduling.test.ts | 26 + 18 files changed, 805 insertions(+), 35 deletions(-) create mode 100644 packages/reflex/src/policy/scheduler/variants/scheduler.ranked.ts diff --git a/packages/@reflex/runtime/src/reactivity/context.ts b/packages/@reflex/runtime/src/reactivity/context.ts index e005d509..aa4eb750 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -13,6 +13,7 @@ export type TrackReadFallback = ( consumer: ReactiveNode, prev: ReactiveEdge | null, nextExpected: ReactiveEdge | null, + version: number, ) => ReactiveEdge; export interface ExecutionContextOptions { @@ -30,6 +31,7 @@ export let trackReadFallback: TrackReadFallback = export class ExecutionContext { activeComputed: ReactiveNode | null = null; + trackingVersion = 0; propagationDepth = 0; cleanupRegistrar: CleanupRegistrar | null = null; trackReadFallback: TrackReadFallback = reuseIncomingEdgeFromSuffixOrCreate; @@ -78,6 +80,7 @@ export class ExecutionContext { resetState(): void { this.activeComputed = null; + this.trackingVersion = 0; this.propagationDepth = 0; this.cleanupRegistrar = null; } diff --git a/packages/@reflex/runtime/src/reactivity/engine/execute.ts b/packages/@reflex/runtime/src/reactivity/engine/execute.ts index 1a0091ca..88c62dcb 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/execute.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/execute.ts @@ -10,11 +10,13 @@ import { defaultContext } from "../context"; 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; diff --git a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts index 1e806552..4022aff9 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -39,10 +39,20 @@ export function tryTrackReadFastPath( consumer: ReactiveNode, ): boolean { const prevEdge = consumer.depsTail; + const version = defaultContext.trackingVersion; const perf = runtimePerfCounters; - if (prevEdge !== null) { + const lastOut = source.lastOut; + if (lastOut != null && lastOut.version === version && lastOut.to === consumer) { + if (perf !== null) { + perf.trackReadDuplicateSourceHit += 1; + } + return true; + } + + if (prevEdge != null) { if (prevEdge.from === source) { + prevEdge.version = version; if (perf !== null) { perf.trackReadDuplicateSourceHit += 1; } @@ -50,7 +60,8 @@ export function tryTrackReadFastPath( } const nextExpected = prevEdge.nextIn; - if (nextExpected !== null && nextExpected.from === source) { + if (nextExpected != null && nextExpected.from === source) { + nextExpected.version = version; if (perf !== null) { perf.trackReadExpectedEdgeHit += 1; } @@ -62,7 +73,8 @@ export function tryTrackReadFastPath( } const firstIn = consumer.firstIn; - if (firstIn !== null && firstIn.from === source) { + if (firstIn != null && firstIn.from === source) { + firstIn.version = version; consumer.depsTail = firstIn; return true; } @@ -74,6 +86,7 @@ export function trackReadActive( source: ReactiveNode, consumer: ReactiveNode, ): void { + const version = defaultContext.trackingVersion; const perf = runtimePerfCounters; if (perf !== null) { perf.trackReadCalls += 1; @@ -106,11 +119,12 @@ export function trackReadActive( if (perf !== null) { perf.trackReadNewEdge += 1; } - 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; } @@ -119,18 +133,25 @@ export function trackReadActive( if (perf !== null) { perf.trackReadNewEdge += 1; } - consumer.depsTail = linkEdge(source, consumer, null); + consumer.depsTail = linkEdge(source, consumer, null, version); return; } if (perf !== null) { perf.trackReadFallbackScan += 1; } - consumer.depsTail = trackReadFallback(source, consumer, null, firstIn); + consumer.depsTail = trackReadFallback( + source, + consumer, + null, + firstIn, + version, + ); return; } if (prevEdge.from === source) { + prevEdge.version = version; if (perf !== null) { perf.trackReadDuplicateSourceHit += 1; } @@ -142,11 +163,12 @@ export function trackReadActive( if (perf !== null) { perf.trackReadNewEdge += 1; } - consumer.depsTail = linkEdge(source, consumer, prevEdge); + consumer.depsTail = linkEdge(source, consumer, prevEdge, version); return; } if (nextExpected.from === source) { + nextExpected.version = version; if (perf !== null) { perf.trackReadExpectedEdgeHit += 1; } @@ -158,7 +180,7 @@ export function trackReadActive( if (perf !== null) { perf.trackReadNewEdge += 1; } - consumer.depsTail = linkEdge(source, consumer, prevEdge); + consumer.depsTail = linkEdge(source, consumer, prevEdge, version); return; } @@ -170,6 +192,7 @@ export function trackReadActive( consumer, prevEdge, nextExpected, + version, ); } diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveEdge.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveEdge.ts index 05ace329..225d9295 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/methods/connect.ts b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts index 8d1987bf..72731765 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts @@ -47,10 +47,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; @@ -84,12 +93,14 @@ export function reuseIncomingEdgeFromSuffixOrCreate( to: ReactiveNode, prev: ReactiveEdge | null, nextExpected: ReactiveEdge | null, + version = 0, ): ReactiveEdge { const perf = runtimePerfCounters; - - // 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 ) { @@ -104,6 +115,7 @@ export function reuseIncomingEdgeFromSuffixOrCreate( attachInEdge(to, edge, prev); } + edge.version = version; return edge; } @@ -111,7 +123,7 @@ export function reuseIncomingEdgeFromSuffixOrCreate( perf.trackReadNewEdge += 1; } - return linkEdge(from, to, prev); + return linkEdge(from, to, prev, version); } export function unlinkDetachedIncomingEdgeSequence( diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index 0f8004f6..aea4c474 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -42,6 +42,29 @@ function dispatchInvalidatedWatcher( 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, @@ -85,8 +108,8 @@ function propagateBranching( const promoteStack = propagatePromoteStack; const stackBase = edgeStack.length; let stackTop = stackBase; - let resume: ReactiveEdge | null = edge.nextOut; - let resumePromote = promote; + let next: ReactiveEdge | null = edge.nextOut; + let nextPromote = promote; const dispatch = dispatchEffectInvalidated; if (parentResume !== null) { @@ -97,10 +120,18 @@ function propagateBranching( while (true) { const sub = edge.to; const subState = sub.state; - const nextState = - (subState & SLOW_INVALIDATION_MASK) === 0 - ? (subState & ~VISITED_MASK) | promote - : getSlowInvalidatedSubscriberState(edge, sub, subState, promote); + 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; @@ -116,14 +147,14 @@ function propagateBranching( if ((nextState & WATCHER_MASK) === 0) { const firstOut = sub.firstOut; if (firstOut !== null) { - if (resume !== null) { - edgeStack[stackTop] = resume; - promoteStack[stackTop++] = resumePromote; + if (next !== null) { + edgeStack[stackTop] = next; + promoteStack[stackTop++] = nextPromote; } edge = firstOut; - resume = edge.nextOut; - promote = resumePromote = NON_IMMEDIATE; + next = edge.nextOut; + promote = nextPromote = NON_IMMEDIATE; continue; } } else { @@ -131,17 +162,17 @@ function propagateBranching( } } - if (resume !== null) { - edge = resume; - promote = resumePromote; - resume = edge.nextOut; + if (next !== null) { + edge = next; + promote = nextPromote; + next = edge.nextOut; continue; } if (stackTop !== stackBase) { edge = edgeStack[--stackTop]!; - promote = resumePromote = promoteStack[stackTop]!; - resume = edge.nextOut; + promote = nextPromote = promoteStack[stackTop]!; + next = edge.nextOut; continue; } 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 edc1d38c..ede422c9 100644 --- a/packages/@reflex/runtime/tests/perf/propagate-stack-compare.jit.mjs +++ b/packages/@reflex/runtime/tests/perf/propagate-stack-compare.jit.mjs @@ -82,6 +82,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 +296,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 +573,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(); @@ -418,6 +812,34 @@ function buildPropagateTree(branching, depth) { }; } +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"); + } + + return { + run(propagateImpl) { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateImpl(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + }; +} + function buildPropagateBranchingTrackingMix(width, depth) { resetRuntime(); @@ -518,6 +940,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,11 +966,28 @@ 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)], + ["hybrid_local", () => scenario.run(propagateHybridLocal)], ]; const samples = new Map(); @@ -543,7 +997,7 @@ function runScenario(label, scenario, iterations, warmup = iterations >> 1, roun } for (let round = 0; round < rounds; round += 1) { - const order = round % 3; + 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); @@ -554,6 +1008,18 @@ 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 hybridMedian = median(samples.get("hybrid_local")); + const profileIterations = Math.min(iterations, 256); + const splitProfile = collectProfile( + () => scenario.run(propagateSplitProfile), + propagateSplitProfile, + profileIterations, + ); + const hybridProfile = collectProfile( + () => scenario.run(propagateHybridProfile), + propagateHybridProfile, + profileIterations, + ); console.log(`\n${label}`); console.log( @@ -567,10 +1033,24 @@ function runScenario(label, scenario, iterations, warmup = iterations >> 1, roun ); console.log( ` int32_local: ${int32Median.toFixed(1)} ns/op (${formatDelta( + int32Median, + importedMedian, + )} vs imported)`, + ); + console.log( + ` hybrid_local: ${hybridMedian.toFixed(1)} ns/op (${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 +1058,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/runtime.duplicate_path.test.ts b/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts index a518912e..50fcf962 100644 --- a/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts +++ b/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts @@ -61,6 +61,27 @@ describe("Reactive runtime - cursor fast path", () => { expect(counters.trackReadExpectedEdgeHit).toBeGreaterThanOrEqual(1); }); + it("makes non-adjacent duplicate reads inert within the same pass via edge versioning", () => { + const counters = createRuntimePerfCounters(); + setRuntimePerfCounters(counters); + + const a = createProducer(1); + const b = createProducer(10); + const current = createConsumer( + () => readProducer(a) + readProducer(b) + readProducer(a) + readProducer(b), + ); + + expect(readConsumer(current)).toBe(22); + expect(incomingSources(current)).toEqual([a, b]); + expect(counters.trackReadCalls).toBe(2); + expect(counters.trackReadDuplicateSourceHit).toBeGreaterThanOrEqual(2); + + writeProducer(a, 2); + expect(readConsumer(current)).toBe(24); + expect(incomingSources(current)).toEqual([a, b]); + expect(counters.trackReadCalls).toBe(2); + }); + it("keeps branch flips correct across passes and prunes stale deps", () => { const counters = createRuntimePerfCounters(); setRuntimePerfCounters(counters); diff --git a/packages/@reflex/runtime/tests/runtime.walkers.test.ts b/packages/@reflex/runtime/tests/runtime.walkers.test.ts index 9da48ba8..f80a0014 100644 --- a/packages/@reflex/runtime/tests/runtime.walkers.test.ts +++ b/packages/@reflex/runtime/tests/runtime.walkers.test.ts @@ -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!, IMMEDIATE); + + 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); diff --git a/packages/reflex/package.json b/packages/reflex/package.json index 64a4adfb..05bff931 100644 --- a/packages/reflex/package.json +++ b/packages/reflex/package.json @@ -1,6 +1,6 @@ { "name": "@volynets/reflex", - "version": "0.1.1", + "version": "0.1.3", "type": "module", "description": "Public Reflex facade with a connected runtime", "license": "MIT", diff --git a/packages/reflex/src/api/effect.ts b/packages/reflex/src/api/effect.ts index 717949a8..f6aec03c 100644 --- a/packages/reflex/src/api/effect.ts +++ b/packages/reflex/src/api/effect.ts @@ -98,6 +98,8 @@ 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 diff --git a/packages/reflex/src/infra/runtime.ts b/packages/reflex/src/infra/runtime.ts index 0ed6e4c7..ffe352a4 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -21,6 +21,8 @@ export interface RuntimeOptions { * Controls when invalidated effects are executed. * * - `"flush"` queues reruns until `rt.flush()` is called. + * - `"ranked"` queues reruns until `rt.flush()` and then drains higher-rank + * watchers before lower-rank ones. * - `"sab"` keeps lazy enqueue semantics but stabilizes effects after the * outermost `rt.batch()` exits. * - `"eager"` flushes reruns automatically. @@ -131,7 +133,8 @@ export interface Runtime { * * @param options - Optional runtime configuration: * - `effectStrategy` controls whether invalidated effects flush on - * `rt.flush()`, stabilize after the outermost batch, or run automatically. + * `rt.flush()`, flush in rank order, stabilize after the outermost batch, or + * run automatically. * - `hooks` installs low-level runtime hooks that are composed with Reflex's * scheduler integration. * diff --git a/packages/reflex/src/policy/scheduler/scheduler.constants.ts b/packages/reflex/src/policy/scheduler/scheduler.constants.ts index 035f38d2..d5c49879 100644 --- a/packages/reflex/src/policy/scheduler/scheduler.constants.ts +++ b/packages/reflex/src/policy/scheduler/scheduler.constants.ts @@ -4,6 +4,7 @@ export const enum EffectSchedulerMode { Flush = 0, Eager = 1, SAB = 2, + Ranked = 3, } export const enum SchedulerPhase { diff --git a/packages/reflex/src/policy/scheduler/scheduler.infra.ts b/packages/reflex/src/policy/scheduler/scheduler.infra.ts index 81684a35..105a926d 100644 --- a/packages/reflex/src/policy/scheduler/scheduler.infra.ts +++ b/packages/reflex/src/policy/scheduler/scheduler.infra.ts @@ -4,16 +4,18 @@ import { EffectSchedulerMode } from "./scheduler.constants"; import type { EffectScheduler } from "./scheduler.types"; import { createEagerScheduler, + createRankedScheduler, createSabScheduler, createFlushScheduler, } from "./variants"; -export type EffectStrategy = "flush" | "eager" | "sab"; +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( @@ -29,6 +31,8 @@ export function createEffectScheduler( switch (mode) { case EffectSchedulerMode.Eager: return createEagerScheduler(context); + case EffectSchedulerMode.Ranked: + return createRankedScheduler(context); case EffectSchedulerMode.SAB: return createSabScheduler(context); default: diff --git a/packages/reflex/src/policy/scheduler/variants/index.ts b/packages/reflex/src/policy/scheduler/variants/index.ts index e10a7180..a4194a81 100644 --- a/packages/reflex/src/policy/scheduler/variants/index.ts +++ b/packages/reflex/src/policy/scheduler/variants/index.ts @@ -1,3 +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.ranked.ts b/packages/reflex/src/policy/scheduler/variants/scheduler.ranked.ts new file mode 100644 index 00000000..096946cd --- /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/tests/reflex.policy.test.ts b/packages/reflex/tests/reflex.policy.test.ts index 0a13d83d..480498d7 100644 --- a/packages/reflex/tests/reflex.policy.test.ts +++ b/packages/reflex/tests/reflex.policy.test.ts @@ -33,6 +33,9 @@ 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); }); diff --git a/packages/reflex/tests/reflex.scheduling.test.ts b/packages/reflex/tests/reflex.scheduling.test.ts index 435cb6b5..288e1b99 100644 --- a/packages/reflex/tests/reflex.scheduling.test.ts +++ b/packages/reflex/tests/reflex.scheduling.test.ts @@ -81,6 +81,32 @@ describe("createEffectScheduler", () => { // 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); From 279969ca19a9690ea4d364baa73ea4295e22ff7a Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Mon, 13 Apr 2026 14:26:59 +0300 Subject: [PATCH 13/14] feat: update package version to 0.2.0 and add typecheck for tests feat: enhance computed and memo functions to return typed Computed and Memo feat: modify signal function to return typed Signal feat: add model-related types and functions for creating models and managing state feat: implement runtime debugging features with detailed event tracking test: add comprehensive tests for model actions, including validation and disposal test: introduce type checking tests for model shapes and actions chore: update tsconfig for type tests --- Readme.md | 21 +- packages/@reflex/runtime/RUNTIME.md | 13 +- packages/@reflex/runtime/package.json | 4 +- packages/@reflex/runtime/rollup.config.ts | 31 +- packages/@reflex/runtime/src/api/read.ts | 27 +- packages/@reflex/runtime/src/api/write.ts | 10 +- packages/@reflex/runtime/src/debug.impl.ts | 336 ++++++++++++++ packages/@reflex/runtime/src/debug.install.ts | 26 ++ packages/@reflex/runtime/src/debug.runtime.ts | 155 +++++++ packages/@reflex/runtime/src/debug.ts | 433 +----------------- packages/@reflex/runtime/src/debug.types.ts | 91 ++++ packages/@reflex/runtime/src/debug_flag.ts | 7 + packages/@reflex/runtime/src/index.ts | 16 - .../@reflex/runtime/src/reactivity/context.ts | 2 +- .../@reflex/runtime/src/reactivity/dev.ts | 2 +- .../runtime/src/reactivity/engine/execute.ts | 2 +- .../runtime/src/reactivity/engine/tracking.ts | 64 +-- .../runtime/src/reactivity/engine/watcher.ts | 20 +- .../@reflex/runtime/src/reactivity/perf.ts | 37 -- .../src/reactivity/shape/methods/connect.ts | 9 - .../reactivity/walkers/propagate.constants.ts | 49 +- .../src/reactivity/walkers/propagate.once.ts | 2 +- .../src/reactivity/walkers/propagate.ts | 2 +- packages/@reflex/runtime/src/subtle.ts | 14 +- .../perf/duplicate-read-single-source.jit.mjs | 148 ------ .../perf/propagate-stack-compare.jit.mjs | 219 ++++++++- .../tests/runtime.duplicate_path.test.ts | 131 ------ .../runtime/tests/runtime.subtle.test.ts | 2 +- .../runtime/tests/runtime.walkers.test.ts | 18 +- .../runtime.walkers_reggression.dev.test.ts | 2 +- packages/reflex/README.md | 111 +++++ packages/reflex/package.json | 3 +- packages/reflex/src/api/derived.ts | 9 +- packages/reflex/src/api/signal.ts | 5 +- packages/reflex/src/globals.d.ts | 4 + packages/reflex/src/index.ts | 10 +- packages/reflex/src/infra/index.ts | 1 + packages/reflex/src/infra/model.ts | 255 +++++++++++ packages/reflex/src/infra/modelValue.ts | 49 ++ packages/reflex/src/infra/runtime.ts | 127 +---- packages/reflex/tests/reflex.exports.test.ts | 5 + packages/reflex/tests/reflex.model.test.ts | 170 +++++++ .../reflex/tests/reflex.model.typecheck.ts | 33 ++ packages/reflex/tests/reflex.policy.test.ts | 1 + packages/reflex/tsconfig.type-tests.json | 4 + 45 files changed, 1619 insertions(+), 1061 deletions(-) create mode 100644 packages/@reflex/runtime/src/debug.impl.ts create mode 100644 packages/@reflex/runtime/src/debug.install.ts create mode 100644 packages/@reflex/runtime/src/debug.runtime.ts create mode 100644 packages/@reflex/runtime/src/debug.types.ts create mode 100644 packages/@reflex/runtime/src/debug_flag.ts delete mode 100644 packages/@reflex/runtime/src/reactivity/perf.ts delete mode 100644 packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs delete mode 100644 packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts create mode 100644 packages/reflex/src/infra/model.ts create mode 100644 packages/reflex/src/infra/modelValue.ts create mode 100644 packages/reflex/tests/reflex.model.test.ts create mode 100644 packages/reflex/tests/reflex.model.typecheck.ts create mode 100644 packages/reflex/tsconfig.type-tests.json diff --git a/Readme.md b/Readme.md index 4f04c276..e0e6a2c2 100644 --- a/Readme.md +++ b/Readme.md @@ -30,6 +30,9 @@ Public application-facing facade. - `computed` - `memo` - `effect` +- `createModel` +- `own` +- `isModel` - `createRuntime` - `map` / `filter` / `merge` - `scan` / `hold` / `subscribeOnce` @@ -39,18 +42,28 @@ Public application-facing facade. 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/packages/@reflex/runtime/RUNTIME.md b/packages/@reflex/runtime/RUNTIME.md index f125b969..955b0396 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 65836ca0..8a110c9d 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 a63dd9e8..0835b7c3 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/src/api/read.ts b/packages/@reflex/runtime/src/api/read.ts index 8c725c17..2cf6b012 100644 --- a/packages/@reflex/runtime/src/api/read.ts +++ b/packages/@reflex/runtime/src/api/read.ts @@ -78,18 +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) { - if (!tryTrackReadFastPath(node, activeComputed)) { - trackReadActive(node, activeComputed); - } + 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; @@ -145,10 +142,12 @@ function stabilizeConsumer(node: ReactiveNode): T { return node.payload as T; } - if (shouldRecomputeDirtyConsumer(node, state)) { - if (recompute(node) && node.firstOut !== null) { - propagateOnce(node); - } + if ( + shouldRecomputeDirtyConsumer(node, state) && + recompute(node) && + node.firstOut !== null + ) { + propagateOnce(node); } else { clearDirtyState(node); } @@ -178,10 +177,8 @@ export function readConsumerLazy(node: ReactiveNode): T { const context = defaultContext; const activeComputed = context.activeComputed; - if (activeComputed !== null) { - if (!tryTrackReadFastPath(node, activeComputed)) { - trackReadActive(node, activeComputed); - } + 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 5f9bffca..fe101df0 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 00000000..296a0752 --- /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 00000000..ffa96566 --- /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 00000000..9f4bdf3e --- /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 7bab2d61..0b4cc79e 100644 --- a/packages/@reflex/runtime/src/debug.ts +++ b/packages/@reflex/runtime/src/debug.ts @@ -1,414 +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(); -const invalidContextKey = {}; -const invalidNodeIds = new Map(); - -function isObjectKey(value: unknown): value is object { - return (typeof value === "object" || typeof value === "function") && value !== null; -} - -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 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; -} +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 00000000..87f938b4 --- /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 00000000..aecd8544 --- /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 caca6d47..032f2b4b 100644 --- a/packages/@reflex/runtime/src/index.ts +++ b/packages/@reflex/runtime/src/index.ts @@ -26,12 +26,6 @@ export { type TrackReadFallback, } from "./reactivity/context"; -export { - createRuntimePerfCounters, - setRuntimePerfCounters, - type RuntimePerfCounters, -} from "./reactivity/perf"; - export { DIRTY_STATE, // @@ -54,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 aa4eb750..b2ca313c 100644 --- a/packages/@reflex/runtime/src/reactivity/context.ts +++ b/packages/@reflex/runtime/src/reactivity/context.ts @@ -1,6 +1,6 @@ import type { ReactiveEdge, ReactiveNode } from "./shape"; import { reuseIncomingEdgeFromSuffixOrCreate } from "./shape/methods/connect"; -import { recordDebugEvent } from "../debug"; +import { recordDebugEvent } from "../debug.runtime"; export interface EngineHooks { onEffectInvalidated?(node: ReactiveNode): void; diff --git a/packages/@reflex/runtime/src/reactivity/dev.ts b/packages/@reflex/runtime/src/reactivity/dev.ts index 86775ead..19982b4e 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 88c62dcb..345d663e 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, diff --git a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts index 4022aff9..03ed73b0 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/tracking.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/tracking.ts @@ -10,7 +10,6 @@ import { unlinkDetachedIncomingEdgeSequence, } from "../shape/methods/connect"; import { defaultContext, trackReadFallback } from "../context"; -import { runtimePerfCounters } from "../perf"; /** * Cursor-guided incoming-edge walk used during dependency collection. @@ -40,31 +39,22 @@ export function tryTrackReadFastPath( ): boolean { const prevEdge = consumer.depsTail; const version = defaultContext.trackingVersion; - const perf = runtimePerfCounters; const lastOut = source.lastOut; if (lastOut != null && lastOut.version === version && lastOut.to === consumer) { - if (perf !== null) { - perf.trackReadDuplicateSourceHit += 1; - } + return true; } if (prevEdge != null) { if (prevEdge.from === source) { prevEdge.version = version; - if (perf !== null) { - perf.trackReadDuplicateSourceHit += 1; - } return true; } const nextExpected = prevEdge.nextIn; if (nextExpected != null && nextExpected.from === source) { nextExpected.version = version; - if (perf !== null) { - perf.trackReadExpectedEdgeHit += 1; - } consumer.depsTail = nextExpected; return true; } @@ -87,19 +77,10 @@ export function trackReadActive( consumer: ReactiveNode, ): void { const version = defaultContext.trackingVersion; - const perf = runtimePerfCounters; - if (perf !== null) { - perf.trackReadCalls += 1; - perf.trackReadWhileActive += 1; - } - const sourceDead = isDisposedNode(source); const consumerDead = isDisposedNode(consumer); - if (sourceDead || consumerDead) { - if (perf !== null) { - perf.trackReadDisposedSkip += 1; - } + if (sourceDead || consumerDead) { if (__DEV__) { devAssertTrackReadAlive(sourceDead, consumerDead); } @@ -116,9 +97,6 @@ export function trackReadActive( const firstIn = consumer.firstIn; if (firstIn === null) { - if (perf !== null) { - perf.trackReadNewEdge += 1; - } consumer.depsTail = linkEdge(source, consumer, null, version); return; } @@ -130,16 +108,10 @@ export function trackReadActive( } if (firstIn.nextIn === null) { - if (perf !== null) { - perf.trackReadNewEdge += 1; - } consumer.depsTail = linkEdge(source, consumer, null, version); return; } - if (perf !== null) { - perf.trackReadFallbackScan += 1; - } consumer.depsTail = trackReadFallback( source, consumer, @@ -152,41 +124,26 @@ export function trackReadActive( if (prevEdge.from === source) { prevEdge.version = version; - if (perf !== null) { - perf.trackReadDuplicateSourceHit += 1; - } return; } const nextExpected = prevEdge.nextIn; if (nextExpected === null) { - if (perf !== null) { - perf.trackReadNewEdge += 1; - } consumer.depsTail = linkEdge(source, consumer, prevEdge, version); return; } if (nextExpected.from === source) { nextExpected.version = version; - if (perf !== null) { - perf.trackReadExpectedEdgeHit += 1; - } consumer.depsTail = nextExpected; return; } if (nextExpected.nextIn === null) { - if (perf !== null) { - perf.trackReadNewEdge += 1; - } consumer.depsTail = linkEdge(source, consumer, prevEdge, version); return; } - if (perf !== null) { - perf.trackReadFallbackScan += 1; - } consumer.depsTail = trackReadFallback( source, consumer, @@ -201,11 +158,6 @@ export function trackReadActive( * Everything after depsTail belongs to the old dependency list and is unlinked. */ export function cleanupStaleSources(node: ReactiveNode): void { - const perf = runtimePerfCounters; - if (perf !== null) { - perf.cleanupPassCount += 1; - } - const tail = node.depsTail; const staleHead = tail === null ? node.firstIn : tail.nextIn; if (staleHead === null) return; @@ -223,17 +175,5 @@ export function cleanupStaleSources(node: ReactiveNode): void { devRecordCleanupStaleSources(node, detachedStaleHead, defaultContext); } - if (perf !== null) { - let removedCount = 0; - for ( - let edge: typeof detachedStaleHead | null = detachedStaleHead; - edge !== null; - edge = edge.nextIn - ) { - removedCount += 1; - } - perf.cleanupStaleEdgeCount += removedCount; - } - unlinkDetachedIncomingEdgeSequence(detachedStaleHead); } diff --git a/packages/@reflex/runtime/src/reactivity/engine/watcher.ts b/packages/@reflex/runtime/src/reactivity/engine/watcher.ts index 1aaec7eb..43708aa6 100644 --- a/packages/@reflex/runtime/src/reactivity/engine/watcher.ts +++ b/packages/@reflex/runtime/src/reactivity/engine/watcher.ts @@ -1,7 +1,5 @@ -import { recordDebugEvent } from "../../debug"; -import { - shouldRecomputeDirtyWatcher, -} from "../walkers/recompute"; +import { recordDebugEvent } from "../../debug.runtime"; +import { shouldRecomputeDirtyWatcher } from "../walkers/recompute"; import type { ReactiveNode } from "../shape"; import { clearNodeVisited, @@ -65,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 (!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) { @@ -110,9 +108,7 @@ export function runWatcher(node: ReactiveNode): void { (node.state & ~ReactiveNodeState.Changed) | ReactiveNodeState.Invalid; } - if (__DEV__) { - recordWatcherFinish(node, hasCleanup, result); - } + if (__DEV__) recordWatcherFinish(node, hasCleanup, result); } export function disposeWatcher(node: ReactiveNode): void { diff --git a/packages/@reflex/runtime/src/reactivity/perf.ts b/packages/@reflex/runtime/src/reactivity/perf.ts deleted file mode 100644 index 16952f1f..00000000 --- a/packages/@reflex/runtime/src/reactivity/perf.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface RuntimePerfCounters { - cleanupPassCount: number; - cleanupStaleEdgeCount: number; - trackReadCalls: number; - trackReadDisposedSkip: number; - trackReadDuplicateSourceHit: number; - trackReadExpectedEdgeHit: number; - trackReadFallbackScan: number; - trackReadNewEdge: number; - trackReadReorder: number; - trackReadWhileActive: number; -} - -export let runtimePerfCounters: RuntimePerfCounters | null = null; - -export function createRuntimePerfCounters(): RuntimePerfCounters { - return { - cleanupPassCount: 0, - cleanupStaleEdgeCount: 0, - trackReadCalls: 0, - trackReadDisposedSkip: 0, - trackReadDuplicateSourceHit: 0, - trackReadExpectedEdgeHit: 0, - trackReadFallbackScan: 0, - trackReadNewEdge: 0, - trackReadReorder: 0, - trackReadWhileActive: 0, - }; -} - -export function setRuntimePerfCounters( - counters: RuntimePerfCounters | null, -): RuntimePerfCounters | null { - const previous = runtimePerfCounters; - runtimePerfCounters = counters; - return previous; -} diff --git a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts index 72731765..66416219 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/methods/connect.ts @@ -2,7 +2,6 @@ import { clearReactiveEdgeLinks, ReactiveEdge } from "../ReactiveEdge"; import { isDisposedNode, markDisposedNode } from "../ReactiveMeta"; import type ReactiveNode from "../ReactiveNode"; import { UNINITIALIZED } from "../ReactiveNode"; -import { runtimePerfCounters } from "../../perf"; // ─── Internal helpers ──────────────────────────────────────────────────────── @@ -95,7 +94,6 @@ export function reuseIncomingEdgeFromSuffixOrCreate( nextExpected: ReactiveEdge | null, version = 0, ): ReactiveEdge { - const perf = runtimePerfCounters; // 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. @@ -108,9 +106,6 @@ export function reuseIncomingEdgeFromSuffixOrCreate( // Found one — reposition it if it's out of order. if (edge.prevIn !== prev) { - if (perf !== null) { - perf.trackReadReorder += 1; - } detachInEdge(to, edge); attachInEdge(to, edge, prev); } @@ -119,10 +114,6 @@ export function reuseIncomingEdgeFromSuffixOrCreate( return edge; } - if (perf !== null) { - perf.trackReadNewEdge += 1; - } - return linkEdge(from, to, prev, version); } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts index 71fc2930..4deef2c9 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 ff05fea7..274fc4fe 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -1,4 +1,4 @@ -import { recordDebugEvent } from "../../debug"; +import { recordDebugEvent } from "../../debug.runtime"; import { defaultContext, dispatchEffectInvalidated } from "../context"; import { devAssertPropagateAlive } from "../dev"; import type { ReactiveNode } from "../shape"; diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index aea4c474..c2c099fe 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -1,4 +1,4 @@ -import { recordDebugEvent } from "../../debug"; +import { recordDebugEvent } from "../../debug.runtime"; import { defaultContext, dispatchEffectInvalidated } from "../context"; import { devAssertPropagateAlive } from "../dev"; import { diff --git a/packages/@reflex/runtime/src/subtle.ts b/packages/@reflex/runtime/src/subtle.ts index 8a31c54b..01455cbc 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/duplicate-read-single-source.jit.mjs b/packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs deleted file mode 100644 index 99c46a09..00000000 --- a/packages/@reflex/runtime/tests/perf/duplicate-read-single-source.jit.mjs +++ /dev/null @@ -1,148 +0,0 @@ -import { performance } from "node:perf_hooks"; -import { - CONSUMER_INITIAL_STATE, - PRODUCER_INITIAL_STATE, - ReactiveNode, - WATCHER_INITIAL_STATE, - createRuntimePerfCounters, - readConsumer, - readProducer, - resetDefaultContext, - runWatcher, - setRuntimePerfCounters, - 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 createScenario(kind) { - resetDefaultContext(); - - const counters = createRuntimePerfCounters(); - setRuntimePerfCounters(counters); - - const head = createProducer(0); - const current = createConsumer(() => { - let result = 0; - - if (kind === "repeated") { - for (let i = 0; i < 20; i += 1) { - result += readProducer(head); - } - return result; - } - - const value = readProducer(head); - for (let i = 0; i < 20; i += 1) { - result += value; - } - - return result; - }); - const effect = createWatcher(() => readConsumer(current)); - - runWatcher(effect); - - return { - counters, - run(iteration) { - writeProducer(head, iteration); - runWatcher(effect); - return readConsumer(current); - }, - }; -} - -function printCounters(label, counters, iterations) { - console.log(`${label}:`); - console.log( - JSON.stringify( - { - ...counters, - trackReadCallsPerUniqueDep: counters.trackReadCalls, - trackReadCallsPerWrite: counters.trackReadCalls / iterations, - }, - null, - 2, - ), - ); -} - -function runProfile(label, kind, iterations) { - const scenario = createScenario(kind); - - for (let i = 0; i < iterations; i += 1) { - scenario.run(i); - } - - printCounters(label, scenario.counters, iterations); -} - -function runSuite() { - const iterations = 30000; - const warmup = 5000; - - bench( - "duplicate_read_single_source", - (() => { - const scenario = createScenario("repeated"); - return (i) => scenario.run(i); - })(), - iterations, - warmup, - ); - - bench( - "duplicate_read_single_source_cached", - (() => { - const scenario = createScenario("cached"); - return (i) => scenario.run(i); - })(), - iterations, - warmup, - ); - - runProfile("duplicate_read_single_source_profile", "repeated", 100); - runProfile("duplicate_read_single_source_cached_profile", "cached", 100); - setRuntimePerfCounters(null); -} - -runSuite(); 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 ede422c9..6da27e74 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(); @@ -741,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; }, @@ -772,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; }, @@ -803,9 +866,40 @@ 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; }, @@ -830,11 +924,48 @@ function buildTrackedPrefixStress(fanIn, depsTailIndex, edgeIndex) { throw new Error("tracked-prefix stress graph is incomplete"); } + function baseline() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + return target.state; + } + return { - run(propagateImpl) { + 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; - propagateImpl(targetEdge, IMMEDIATE, runtime); + propagateSplitProfile(targetEdge, IMMEDIATE, runtime); + return target.state; + }, + hybridProfile() { + target.state = TRACKING_CONSUMER_STATE; + target.depsTail = depsTail; + propagateHybridProfile(targetEdge, IMMEDIATE, runtime); return target.state; }, }; @@ -905,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; }, }; @@ -984,19 +1144,22 @@ function printProfileLine(label, profile) { 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)], - ["hybrid_local", () => scenario.run(propagateHybridLocal)], + ["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) { + 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]; @@ -1005,40 +1168,48 @@ 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 hybridMedian = median(samples.get("hybrid_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.run(propagateSplitProfile), + scenario.splitProfile, propagateSplitProfile, profileIterations, ); const hybridProfile = collectProfile( - () => scenario.run(propagateHybridProfile), + scenario.hybridProfile, propagateHybridProfile, profileIterations, ); console.log(`\n${label}`); console.log( - ` imported: ${importedMedian.toFixed(1)} ns/op`, + ` baseline: ${baselineMedian.toFixed(1)} ns/op`, + ); + console.log( + ` imported: ${importedMedian.toFixed(1)} ns/op adj (${importedMedianRaw.toFixed(1)} raw)`, ); console.log( - ` array_local: ${arrayMedian.toFixed(1)} ns/op (${formatDelta( + ` 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 (${formatDelta( + ` hybrid_local: ${hybridMedian.toFixed(1)} ns/op adj (${hybridMedianRaw.toFixed(1)} raw, ${formatDelta( hybridMedian, importedMedian, )} vs imported)`, diff --git a/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts b/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts deleted file mode 100644 index 50fcf962..00000000 --- a/packages/@reflex/runtime/tests/runtime.duplicate_path.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - createRuntimePerfCounters, - readConsumer, - readProducer, - setRuntimePerfCounters, - writeProducer, -} from "../src"; -import { - createConsumer, - createProducer, - incomingSources, - resetRuntime, -} from "./runtime.test_utils"; - -describe("Reactive runtime - cursor fast path", () => { - beforeEach(() => { - resetRuntime(); - }); - - afterEach(() => { - setRuntimePerfCounters(null); - }); - - it("makes consecutive duplicate reads structurally inert after the first link", () => { - const counters = createRuntimePerfCounters(); - setRuntimePerfCounters(counters); - - const head = createProducer(1); - const current = createConsumer( - () => readProducer(head) + readProducer(head) + readProducer(head), - ); - - expect(readConsumer(current)).toBe(3); - expect(incomingSources(current)).toEqual([head]); - expect(counters.trackReadCalls).toBe(1); - expect(counters.trackReadDuplicateSourceHit).toBeGreaterThanOrEqual(2); - - writeProducer(head, 2); - expect(readConsumer(current)).toBe(6); - expect(incomingSources(current)).toEqual([head]); - expect(counters.trackReadCalls).toBe(1); - }); - - it("reuses stable expected order across passes without re-entering trackReadActive", () => { - const counters = createRuntimePerfCounters(); - setRuntimePerfCounters(counters); - - const a = createProducer(1); - const b = createProducer(10); - const current = createConsumer(() => readProducer(a) + readProducer(b)); - - expect(readConsumer(current)).toBe(11); - expect(incomingSources(current)).toEqual([a, b]); - expect(counters.trackReadCalls).toBe(2); - - writeProducer(a, 2); - expect(readConsumer(current)).toBe(12); - expect(incomingSources(current)).toEqual([a, b]); - expect(counters.trackReadCalls).toBe(2); - expect(counters.trackReadExpectedEdgeHit).toBeGreaterThanOrEqual(1); - }); - - it("makes non-adjacent duplicate reads inert within the same pass via edge versioning", () => { - const counters = createRuntimePerfCounters(); - setRuntimePerfCounters(counters); - - const a = createProducer(1); - const b = createProducer(10); - const current = createConsumer( - () => readProducer(a) + readProducer(b) + readProducer(a) + readProducer(b), - ); - - expect(readConsumer(current)).toBe(22); - expect(incomingSources(current)).toEqual([a, b]); - expect(counters.trackReadCalls).toBe(2); - expect(counters.trackReadDuplicateSourceHit).toBeGreaterThanOrEqual(2); - - writeProducer(a, 2); - expect(readConsumer(current)).toBe(24); - expect(incomingSources(current)).toEqual([a, b]); - expect(counters.trackReadCalls).toBe(2); - }); - - it("keeps branch flips correct across passes and prunes stale deps", () => { - const counters = createRuntimePerfCounters(); - setRuntimePerfCounters(counters); - - const flag = createProducer(true); - const left = createProducer(2); - const right = createProducer(7); - const current = createConsumer(() => - readProducer(flag) ? readProducer(left) : readProducer(right), - ); - - expect(readConsumer(current)).toBe(2); - expect(incomingSources(current)).toEqual([flag, left]); - - writeProducer(flag, false); - expect(readConsumer(current)).toBe(7); - expect(incomingSources(current)).toEqual([flag, right]); - expect(counters.cleanupPassCount).toBeGreaterThanOrEqual(1); - expect(counters.cleanupStaleEdgeCount).toBeGreaterThanOrEqual(1); - - writeProducer(left, 5); - expect(readConsumer(current)).toBe(7); - expect(incomingSources(current)).toEqual([flag, right]); - }); - - it("preserves savings across a deeper nested chain of duplicate reads", () => { - const counters = createRuntimePerfCounters(); - setRuntimePerfCounters(counters); - - const head = createProducer(1); - const chain = [createConsumer(() => readProducer(head) + readProducer(head))]; - - for (let i = 1; i < 6; i += 1) { - const prev = chain[i - 1]; - chain.push(createConsumer(() => readConsumer(prev) + readConsumer(prev))); - } - - const root = chain[chain.length - 1]; - - expect(readConsumer(root)).toBe(64); - expect(counters.trackReadCalls).toBe(6); - - writeProducer(head, 2); - expect(readConsumer(root)).toBe(128); - expect(counters.trackReadCalls).toBe(6); - }); -}); diff --git a/packages/@reflex/runtime/tests/runtime.subtle.test.ts b/packages/@reflex/runtime/tests/runtime.subtle.test.ts index b00e0e55..d92c1a3f 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 f80a0014..707e4f53 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, @@ -77,7 +77,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, @@ -108,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, @@ -135,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, @@ -160,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, @@ -191,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 | @@ -214,7 +214,7 @@ 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, @@ -237,7 +237,7 @@ 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, 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 26bbcc4b..794747b1 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/README.md b/packages/reflex/README.md index 58a7c64c..1ccc9a20 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,78 @@ 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 + +#### Model Semantics (Contract) + +1. Lifecycle + - created when you call the factory returned by `createModel(...)` + - disposed when `model[Symbol.dispose]()` is called + - 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) +3. Action semantics + - actions can be nested; each action runs inside the active `batch()` + - actions run untracked; dependency tracking is suspended during the action + - if an action throws, the error is rethrown and tracking/batch state is restored + - actions are synchronous for reactive correctness; async work runs outside the batch/untracked scope +4. Post-dispose behavior + - actions always throw after disposal + - reads of returned accessors may still succeed, but the model is considered dead and behavior is not guaranteed + - effects are not allowed in models; subscriptions or external resources must be torn down via `ctx.onDispose()`/`own()` +5. Visibility + - `own(ctx, value)` is public but intended for implementation detail (ownership, teardown) + - anything returned from the model is part of its public API + - keep internal details private by not returning them, or document them explicitly + ### Event composition ```ts @@ -257,6 +331,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. diff --git a/packages/reflex/package.json b/packages/reflex/package.json index 05bff931..f7538d70 100644 --- a/packages/reflex/package.json +++ b/packages/reflex/package.json @@ -1,6 +1,6 @@ { "name": "@volynets/reflex", - "version": "0.1.3", + "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 95738d7b..c354b921 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(node); - return readConsumerLazy.bind(null, node) as Accessor; + return markModelReadable(readConsumerLazy.bind(null, node) as Memo); } diff --git a/packages/reflex/src/api/signal.ts b/packages/reflex/src/api/signal.ts index f38f9515..10f9a4fd 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,7 +50,7 @@ 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) { @@ -62,7 +63,7 @@ export function signal(initialValue: T): readonly [Accessor, Setter] { } 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 7028b4b5..33d2a31b 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 508797f0..43770415 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/index.ts b/packages/reflex/src/infra/index.ts index 5ca88606..fe092a06 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 00000000..03317b8c --- /dev/null +++ b/packages/reflex/src/infra/model.ts @@ -0,0 +1,255 @@ +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; + +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 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 { + 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)`. + */ +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, DISPOSE, { + value() { + if (state.disposed) return; + state.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 00000000..b24b56fe --- /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 ffe352a4..1c765118 100644 --- a/packages/reflex/src/infra/runtime.ts +++ b/packages/reflex/src/infra/runtime.ts @@ -9,26 +9,12 @@ import { resolveEffectSchedulerMode, } from "../policy/scheduler"; +type BatchFn = (fn: () => T) => T; + +let activeBatch: BatchFn = (fn) => fn(); + export 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. - */ hooks?: EngineHooks; - /** - * Controls when invalidated effects are executed. - * - * - `"flush"` queues reruns until `rt.flush()` is called. - * - `"ranked"` queues reruns until `rt.flush()` and then drains higher-rank - * watchers before lower-rank ones. - * - `"sab"` keeps lazy enqueue semantics but stabilizes effects after the - * outermost `rt.batch()` exits. - * - `"eager"` flushes reruns automatically. - * - * @default "flush" - */ effectStrategy?: EffectStrategy; } @@ -55,122 +41,31 @@ 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. In `"sab"` and - * `"eager"` it remains available as an explicit synchronization escape hatch. - */ 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()`, flush in rank order, stabilize after the outermost batch, or - * run 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); + + activeBatch = scheduler.batch; + return { ctx: executionContext, batch: scheduler.batch, diff --git a/packages/reflex/tests/reflex.exports.test.ts b/packages/reflex/tests/reflex.exports.test.ts index 84418627..466c8ac1 100644 --- a/packages/reflex/tests/reflex.exports.test.ts +++ b/packages/reflex/tests/reflex.exports.test.ts @@ -6,6 +6,7 @@ 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", () => { @@ -19,6 +20,10 @@ 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); }); diff --git a/packages/reflex/tests/reflex.model.test.ts b/packages/reflex/tests/reflex.model.test.ts new file mode 100644 index 00000000..24e6d240 --- /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 00000000..a3bef524 --- /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 480498d7..e0827a8c 100644 --- a/packages/reflex/tests/reflex.policy.test.ts +++ b/packages/reflex/tests/reflex.policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +vi.unmock("@reflex/runtime"); import { createWatcherNode } from "../src/infra/factory"; import { createEffectScheduler, diff --git a/packages/reflex/tsconfig.type-tests.json b/packages/reflex/tsconfig.type-tests.json new file mode 100644 index 00000000..070e719d --- /dev/null +++ b/packages/reflex/tsconfig.type-tests.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.typecheck.json", + "include": ["src", "tests/reflex.model.typecheck.ts"] +} From 10362cf2a5aaccf7e6cfdd6600c0d3b01881945a Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Mon, 13 Apr 2026 14:34:29 +0300 Subject: [PATCH 14/14] feat: add model contract documentation and improve disposal warnings --- Readme.md | 1 + docs/models.md | 72 ++++++++++++++++++++++++++++++ packages/reflex/README.md | 19 ++++++-- packages/reflex/src/infra/model.ts | 29 ++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 docs/models.md diff --git a/Readme.md b/Readme.md index e0e6a2c2..810b28b2 100644 --- a/Readme.md +++ b/Readme.md @@ -36,6 +36,7 @@ Public application-facing facade. - `createRuntime` - `map` / `filter` / `merge` - `scan` / `hold` / `subscribeOnce` +- model contract: `docs/models.md` ## Recommended Entry Point diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 00000000..9e765f6a --- /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/README.md b/packages/reflex/README.md index 1ccc9a20..cb2740a4 100644 --- a/packages/reflex/README.md +++ b/packages/reflex/README.md @@ -179,32 +179,45 @@ Model rules: - 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 runs inside the active `batch()` + - 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 - - reads of returned accessors may still succeed, but the model is considered dead and behavior is not guaranteed + - 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 - - `own(ctx, value)` is public but intended for implementation detail (ownership, teardown) - 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 diff --git a/packages/reflex/src/infra/model.ts b/packages/reflex/src/infra/model.ts index 03317b8c..992f9a46 100644 --- a/packages/reflex/src/infra/model.ts +++ b/packages/reflex/src/infra/model.ts @@ -12,6 +12,7 @@ type Cleanup = () => void; type ModelState = { disposed: boolean }; const DISPOSE = Symbol.dispose; +const MODEL_DISPOSED = Symbol("MODEL_DISPOSED"); interface DisposableLike { [DISPOSE](): void; @@ -42,6 +43,12 @@ const MODEL_BRAND = Symbol("MODEL_BRAND"); type ModelTypeError = Message; +type DisposedHint = { + disposed?: boolean; + isDisposed?: boolean; + [MODEL_DISPOSED]?: boolean; +}; + type ReadableModelBrand = | Brand<"signal"> | Brand<"computed"> @@ -169,6 +176,18 @@ export function isModel(value: unknown): value is DisposableLike { * 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; } @@ -187,6 +206,9 @@ export function own(ctx: ModelContext, value: T): T { * 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, @@ -226,11 +248,18 @@ export function createModel>( 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];