From 1b77e33f5ff61fe8d6f26565d505aa306c847328 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Wed, 1 Apr 2026 20:22:21 +0300 Subject: [PATCH 1/3] refactor: reorganize imports and replace propagationWatchers with propagation.watchers --- .../reactivity/walkers/propagate.branch.ts | 19 ++++++++------ .../reactivity/walkers/propagate.branching.ts | 25 +++++++++++-------- .../reactivity/walkers/propagate.constants.ts | 11 ++++++++ .../src/reactivity/walkers/propagate.once.ts | 6 ++--- .../src/reactivity/walkers/propagate.ts | 3 ++- ...ionWatchers.ts => propagation.watchers.ts} | 0 6 files changed, 41 insertions(+), 23 deletions(-) rename packages/@reflex/runtime/src/reactivity/walkers/{propagationWatchers.ts => propagation.watchers.ts} (100%) diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts index 647dc13..5f4bca3 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts @@ -1,13 +1,16 @@ import type { ExecutionContext } from "../context"; import type { ReactiveEdge } from "../shape"; import { ReactiveNodeState } from "../shape"; -import { INVALIDATION_SLOW_PATH_MASK, NON_IMMEDIATE } from "./propagate.constants"; -import { propagateBranching } from "./propagate.branching" +import { + INVALIDATION_SLOW_PATH_MASK, + NON_IMMEDIATE, +} from "./propagate.constants"; +import { propagateBranching } from "./propagate.branching"; import { getSlowInvalidatedSubscriberState } from "./propagate.utils"; import { recordPropagation, notifyWatcherInvalidation, -} from "./propagationWatchers"; +} from "./propagation.watchers"; // ─── propagateBranch ────────────────────────────────────────────────────────── // @@ -42,18 +45,18 @@ export function propagateBranch( } else { const firstOut = sub.firstOut; if (firstOut !== null) { - edge = firstOut; if (next !== null) { - // Fanout: escalate. `next` is a sibling at the current promote level. return propagateBranching( - edge, - NON_IMMEDIATE, // child level always starts non-immediate + firstOut, + NON_IMMEDIATE, next, - promote, // fix: siblings inherit current promote, not NON_IMMEDIATE + promote, thrown, context, ); } + + edge = firstOut; promote = NON_IMMEDIATE; continue; } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts index 6826bcd..12967a3 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts @@ -5,7 +5,7 @@ import { NON_IMMEDIATE } from "./propagate.constants"; import { recordPropagation, notifyWatcherInvalidation, -} from "./propagationWatchers"; +} from "./propagation.watchers"; const INVALIDATION_SLOW_PATH_MASK = DIRTY_STATE | ReactiveNodeState.Disposed | WALKER_STATE; @@ -18,10 +18,15 @@ function isTrackedPrefixEdge( depsTail: ReactiveEdge | null, ): boolean { if (depsTail === null) return false; - if (edge === depsTail) return true; - for (let cursor = edge.prevIn; cursor !== null; cursor = cursor.prevIn) { + + for ( + let cursor: ReactiveEdge | null = edge.prevIn; + cursor !== null; + cursor = cursor.prevIn + ) { if (cursor === depsTail) return false; } + return true; } @@ -32,7 +37,6 @@ function isTrackedPrefixEdge( // // Inlining budget: ~20 AST nodes — will be inlined by all three JITs since // both call sites are monomorphic (same edge/state shapes every time). - function getSlowInvalidatedSubscriberState( edge: ReactiveEdge, state: number, @@ -109,8 +113,9 @@ export function propagateBranching( edgeStack[stackTop] = resume; promoteStack[stackTop++] = resumePromote; } + edge = firstOut; - resume = edge.nextOut; + resume = firstOut.nextOut; promote = resumePromote = NON_IMMEDIATE; continue; } @@ -120,18 +125,16 @@ export function propagateBranching( if (resume !== null) { edge = resume; promote = resumePromote; - resume = edge.nextOut; } else if (stackTop > stackBase) { - --stackTop; - edge = edgeStack[stackTop]!; + edge = edgeStack[--stackTop]!; promote = resumePromote = promoteStack[stackTop]!; - resume = edge.nextOut; } else { return thrown; } + + resume = edge.nextOut; } } finally { - edgeStack.length = stackBase; - promoteStack.length = stackBase; + edgeStack.length = promoteStack.length = stackBase; } } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts index 5f57c2e..91cf677 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts @@ -1,6 +1,17 @@ import { DIRTY_STATE, ReactiveNodeState, WALKER_STATE } from "../shape"; +/** + * NON_IMMEDIATE flag: somwhere in the middle subscribers are promoted Invalid. + * + * This tells them "maybe changed, verify dependencies and then recompute" + */ export const NON_IMMEDIATE = 0; + +/** + * IMMEDIATE flag: direct subscribers are promoted from Invalid → Changed. + * + * This tells them "definitely changed, don't verify, recompute" + */ export const IMMEDIATE = 1; export const INVALIDATION_SLOW_PATH_MASK = diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts index 3e4efd7..39a6fbe 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.once.ts @@ -13,7 +13,7 @@ import { IMMEDIATE } from "./propagate.constants"; import { recordPropagation, notifyWatcherInvalidation, -} from "./propagationWatchers"; +} from "./propagation.watchers"; export function propagateOnce( node: ReactiveNode, @@ -27,8 +27,8 @@ export function propagateOnce( let thrown: unknown = null; for (let edge = node.firstOut; edge !== null; edge = edge.nextOut) { - const sub = edge.to, - state = sub.state; + const sub = edge.to; + const state = sub.state; if ((state & DIRTY_STATE) !== ReactiveNodeState.Invalid) continue; diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts index 60460f1..3daef49 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.ts @@ -3,10 +3,11 @@ import { devAssertPropagateAlive } from "../dev"; import { getDefaultContext } from "../context"; import { type ReactiveEdge, ReactiveNodeState } from "../shape"; import { propagateBranch } from "./propagate.branch"; +import { NON_IMMEDIATE } from "./propagate.constants"; export function propagate( startEdge: ReactiveEdge, - promoteImmediate: number = 0, + promoteImmediate: number = NON_IMMEDIATE, context: ExecutionContext = getDefaultContext(), ): void { if ((startEdge.from.state & ReactiveNodeState.Disposed) !== 0) { diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagationWatchers.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagation.watchers.ts similarity index 100% rename from packages/@reflex/runtime/src/reactivity/walkers/propagationWatchers.ts rename to packages/@reflex/runtime/src/reactivity/walkers/propagation.watchers.ts From 810c83f7ec5c419cd4303ba7c26bb0ab6ca0d45e Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Wed, 1 Apr 2026 21:38:44 +0300 Subject: [PATCH 2/3] refactor: enhance reactive node state management and improve propagation logic --- packages/@reflex/runtime/src/api/write.ts | 3 - .../src/reactivity/shape/ReactiveMeta.ts | 44 ++++++++ .../src/reactivity/shape/ReactiveNode.ts | 35 +----- .../reactivity/walkers/propagate.branch.ts | 31 +++--- .../reactivity/walkers/propagate.branching.ts | 105 ++++++++---------- .../reactivity/walkers/propagate.constants.ts | 18 +-- .../src/reactivity/walkers/propagate.utils.ts | 7 +- .../walkers/propagation.watchers.ts | 5 +- .../reactivity/walkers/recompute.branch.ts | 25 ++--- .../reactivity/walkers/recompute.branching.ts | 19 +--- .../reactivity/walkers/recompute.refresh.ts | 24 +--- .../runtime/tests/runtime.walkers.test.ts | 50 +++++++++ 12 files changed, 191 insertions(+), 175 deletions(-) diff --git a/packages/@reflex/runtime/src/api/write.ts b/packages/@reflex/runtime/src/api/write.ts index 741d5fe..cc30df6 100644 --- a/packages/@reflex/runtime/src/api/write.ts +++ b/packages/@reflex/runtime/src/api/write.ts @@ -4,7 +4,6 @@ import type { ExecutionContext } from "../reactivity/context"; import { compare as defaultComparator } from "./compare"; import { devAssertWriteAlive, devRecordWriteProducer } from "../reactivity/dev"; import { - DIRTY_STATE, IMMEDIATE, isDisposedNode, propagate, @@ -90,8 +89,6 @@ export function writeProducer( // Update the payload to the new value node.payload = value; - // Clear any stale dirty bits from before (node is now clean with new value) - node.state &= ~DIRTY_STATE; // Get the first subscriber edge (if any) const firstSubscriberEdge = node.firstOut; diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts index 20cf5c6..06672fd 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveMeta.ts @@ -1,19 +1,44 @@ import type ReactiveNode from "./ReactiveNode"; +/** + * Bit flags describing the current role and lifecycle state of a reactive node. + * + * Layout: + * - exactly one kind bit should normally be present: Producer / Consumer / Watcher + * - dirty bits are mutually exclusive in supported flows: Invalid or Changed + * - walker bits (`Visited`, `Tracking`) are transient and only meaningful during + * propagation / pull-walk execution + * + * High-level semantics: + * - `Changed` means "upstream change is already confirmed, recompute directly" + * - `Invalid` means "upstream may have changed, verify through shouldRecompute()" + * - producers commit on write and should not normally participate in pull-walk + */ export enum ReactiveNodeState { + /** Mutable source node. Holds committed payload directly and never recomputes. */ Producer = 1 << 0, + /** Pure computed node. Re-executes lazily when its dependencies become dirty. */ Consumer = 1 << 1, + /** Effect-like sink. Invalidations schedule or notify work rather than return data. */ Watcher = 1 << 2, + /** Maybe stale: value is not confirmed changed yet and must be verified on read. */ Invalid = 1 << 3, + /** Definitely stale: a direct upstream dependency already confirmed a change. */ Changed = 1 << 4, + /** Re-entrant marker used when a tracked dependency invalidates mid-computation. */ Visited = 1 << 5, + /** Terminal lifecycle state. Disposed nodes must no longer participate in the graph. */ Disposed = 1 << 6, + /** Node is currently executing its compute function. Used for cycle detection. */ Computing = 1 << 7, + /** Watcher has already been scheduled/notified for the current invalidation wave. */ Scheduled = 1 << 8, + /** Node is collecting dependencies during the current computation pass. */ Tracking = 1 << 9, } +/** Mask for the mutually-exclusive node kind bits. */ export const NODE_KIND_STATE = ReactiveNodeState.Producer | ReactiveNodeState.Consumer | @@ -22,56 +47,75 @@ export const NODE_KIND_STATE = // export const MAYBE_CHANGE_STATE = ReactiveNodeState.Invalid; // export const CHANGED_STATE = ReactiveNodeState.Changed; +/** All dirty bits. In supported runtime flows this is either `Invalid` or `Changed`. */ export const DIRTY_STATE = ReactiveNodeState.Invalid | ReactiveNodeState.Changed; +/** Clean producer. Normal steady state for source nodes. */ export const PRODUCER_INITIAL_STATE = ReactiveNodeState.Producer; +/** + * Legacy/testing helper for a producer carrying `Changed`. + * Runtime write flow should normally commit producers immediately instead. + */ export const PRODUCER_CHANGED = ReactiveNodeState.Producer | ReactiveNodeState.Changed; +/** Legacy/testing helper for any dirty producer state. */ export const PRODUCER_DIRTY = ReactiveNodeState.Producer | DIRTY_STATE; +/** Directly invalidated computed node: skip verification and recompute on read. */ export const CONSUMER_CHANGED = ReactiveNodeState.Changed | ReactiveNodeState.Consumer; +/** Computed node carrying either `Invalid` or `Changed`. */ export const CONSUMER_DIRTY = ReactiveNodeState.Consumer | DIRTY_STATE; +/** Directly invalidated watcher. */ export const WATCHER_CHANGED = ReactiveNodeState.Changed | ReactiveNodeState.Watcher; +/** Transient walker-only bits that should not survive a settled execution. */ export const WALKER_STATE = ReactiveNodeState.Visited | ReactiveNodeState.Tracking; +/** Clear the re-entrant marker after the walker no longer needs it. */ export function clearNodeVisited(node: ReactiveNode): void { node.state &= ~ReactiveNodeState.Visited; } +/** Enter dependency collection mode for the current compute pass. */ export function beginNodeTracking(node: ReactiveNode): void { node.state = (node.state & ~ReactiveNodeState.Visited) | ReactiveNodeState.Tracking; } +/** Leave dependency collection mode after compute finishes. */ export function clearNodeTracking(node: ReactiveNode): void { node.state &= ~ReactiveNodeState.Tracking; } +/** Mark a node as actively executing its compute function. */ export function markNodeComputing(node: ReactiveNode): void { node.state |= ReactiveNodeState.Computing; } +/** Clear the active-computation marker. */ export function clearNodeComputing(node: ReactiveNode): void { node.state &= ~ReactiveNodeState.Computing; } +/** Clear both `Invalid` and `Changed`, returning the node to a clean state. */ export function clearDirtyState(node: ReactiveNode): void { node.state &= ~DIRTY_STATE; } +/** Runtime helper for the terminal lifecycle check. */ export function isDisposedNode(node: ReactiveNode): boolean { return (node.state & ReactiveNodeState.Disposed) !== 0; } +/** Collapse a node to kind + disposed, dropping transient execution flags. */ export function markDisposedNode(node: ReactiveNode): void { node.state = (node.state & NODE_KIND_STATE) | ReactiveNodeState.Disposed; } diff --git a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts index 6e0d983..7fdd259 100644 --- a/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts +++ b/packages/@reflex/runtime/src/reactivity/shape/ReactiveNode.ts @@ -7,56 +7,23 @@ const UNINITIALIZED: unique symbol = Symbol.for("UNINITIALIZED"); class ReactiveNode implements Reactivable { state: number; - compute: ComputeFn; firstOut: ReactiveEdge | null; firstIn: ReactiveEdge | null; lastOut: ReactiveEdge | null; lastIn: ReactiveEdge | null; + compute: ComputeFn; depsTail: ReactiveEdge | null; payload: T; constructor(payload: T | undefined, compute: ComputeFn, state: number) { this.state = state; - this.compute = compute; this.firstOut = null; this.firstIn = null; this.lastOut = null; this.lastIn = null; - this.depsTail = null; - this.payload = payload as T; - } -} - -type ComputeFnAsync = () => Promise & Promise; - -export class ReactiveNodeAsync implements ReactiveNode { - phase: number; - - state: number; - compute: ComputeFnAsync; - firstOut: ReactiveEdge | null; - firstIn: ReactiveEdge | null; - lastOut: ReactiveEdge | null; - lastIn: ReactiveEdge | null; - depsTail: ReactiveEdge | null; - payload: T | E; - pending: T | null; - - constructor( - payload: T | undefined, - compute: ComputeFnAsync, - state: number, - ) { - this.phase = 0; - this.state = state; this.compute = compute; - this.firstOut = null; - this.firstIn = null; - this.lastOut = null; - this.lastIn = null; this.depsTail = null; this.payload = payload as T; - this.pending = null; } } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts index 5f4bca3..852c213 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branch.ts @@ -1,10 +1,7 @@ import type { ExecutionContext } from "../context"; import type { ReactiveEdge } from "../shape"; import { ReactiveNodeState } from "../shape"; -import { - INVALIDATION_SLOW_PATH_MASK, - NON_IMMEDIATE, -} from "./propagate.constants"; +import { CAN_ESCAPE_INVALIDATION, NON_IMMEDIATE } from "./propagate.constants"; import { propagateBranching } from "./propagate.branching"; import { getSlowInvalidatedSubscriberState } from "./propagate.utils"; import { @@ -17,28 +14,31 @@ import { // Hot path: tight loop for chains with no fanout. // Escalates to propagateBranching the moment a sibling edge appears. // -// Promotion fix: when escalating, `promote` (not hardcoded NON_IMMEDIATE) is +// Promotion fix: when escalating, `promoteBit` (not hardcoded NON_IMMEDIATE) is // passed as `resumePromote` so the sibling `next` stays in the correct // promotion zone. The child level (firstOut) still resets to NON_IMMEDIATE. export function propagateBranch( edge: ReactiveEdge, - promote: number, + promoteBit: number, thrown: unknown, context: ExecutionContext, ): unknown { while (true) { const sub = edge.to; const state = sub.state; - const nextState = - (state & INVALIDATION_SLOW_PATH_MASK) === 0 - ? state | - (promote ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) - : getSlowInvalidatedSubscriberState(edge, state, promote); + let nextState = 0; + + if ((state & CAN_ESCAPE_INVALIDATION) === 0) { + nextState = state | promoteBit; + } else { + nextState = getSlowInvalidatedSubscriberState(edge, state, promoteBit); + } + const next = edge.nextOut; if (nextState) { sub.state = nextState; - if (__DEV__) recordPropagation(edge, nextState, promote, context); + if (__DEV__) recordPropagation(edge, nextState, promoteBit, context); if ((nextState & ReactiveNodeState.Watcher) !== 0) { thrown = notifyWatcherInvalidation(sub, thrown, context); @@ -48,16 +48,15 @@ export function propagateBranch( if (next !== null) { return propagateBranching( firstOut, - NON_IMMEDIATE, next, - promote, + promoteBit, thrown, context, ); } edge = firstOut; - promote = NON_IMMEDIATE; + promoteBit = NON_IMMEDIATE; continue; } } @@ -65,6 +64,6 @@ export function propagateBranch( if (next === null) return thrown; edge = next; - // promote stays the same: siblings at the same level share promotion status + // promoteBit stays the same: siblings at the same level share promotion status } } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts index 12967a3..4e570bf 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.branching.ts @@ -1,15 +1,12 @@ import type { ExecutionContext } from "../context"; import type { ReactiveEdge } from "../shape"; -import { DIRTY_STATE, ReactiveNodeState, WALKER_STATE } from "../shape"; -import { NON_IMMEDIATE } from "./propagate.constants"; +import { DIRTY_STATE, ReactiveNodeState } from "../shape"; +import { CAN_ESCAPE_INVALIDATION, NON_IMMEDIATE } from "./propagate.constants"; import { recordPropagation, notifyWatcherInvalidation, } from "./propagation.watchers"; -const INVALIDATION_SLOW_PATH_MASK = - DIRTY_STATE | ReactiveNodeState.Disposed | WALKER_STATE; - const propagateEdgeStack: ReactiveEdge[] = []; const propagatePromoteStack: number[] = []; @@ -40,15 +37,12 @@ function isTrackedPrefixEdge( function getSlowInvalidatedSubscriberState( edge: ReactiveEdge, state: number, - promoteImmediate: number, + promoteBit: number, ): number { if ((state & (DIRTY_STATE | ReactiveNodeState.Disposed)) !== 0) return 0; if ((state & ReactiveNodeState.Tracking) === 0) { - return ( - (state & ~ReactiveNodeState.Visited) | - (promoteImmediate ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) - ); + return (state & ~ReactiveNodeState.Visited) | promoteBit; } return isTrackedPrefixEdge(edge, edge.to.depsTail) @@ -64,10 +58,10 @@ function getSlowInvalidatedSubscriberState( // // Changes vs original: // -// 1. promote/resumePromote symmetry fix: -// Original passed (NON_IMMEDIATE, promote) when escalating from linear, -// meaning siblings of the escalation point inherited the wrong promote level. -// Now linear passes its own `promote` as resumePromote so siblings stay +// 1. promoteBit/resumePromote symmetry fix: +// Original passed (NON_IMMEDIATE, promoteBit) when escalating from linear, +// meaning siblings of the escalation point inherited the wrong promoteBit level. +// Now linear passes its own `promoteBit` as resumePromote so siblings stay // in the same promotion zone. // // 2. __DEV__ guard at call site: @@ -79,7 +73,6 @@ function getSlowInvalidatedSubscriberState( export function propagateBranching( edge: ReactiveEdge, - promote: number, resume: ReactiveEdge | null, resumePromote: number, thrown: unknown, @@ -89,52 +82,52 @@ export function propagateBranching( const promoteStack = propagatePromoteStack; const stackBase = edgeStack.length; let stackTop = stackBase; + let promoteBit = NON_IMMEDIATE; - try { - while (true) { - const sub = edge.to; - 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 (__DEV__) recordPropagation(edge, nextState, promote, context); - - 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; - } - - edge = firstOut; - resume = firstOut.nextOut; - promote = resumePromote = NON_IMMEDIATE; - continue; - } - } - } + while (true) { + const sub = edge.to; + const state = sub.state; + let nextState = 0; + + if ((state & CAN_ESCAPE_INVALIDATION) === 0) { + nextState = state | promoteBit; + } else { + nextState = getSlowInvalidatedSubscriberState(edge, state, promoteBit); + } - if (resume !== null) { - edge = resume; - promote = resumePromote; - } else if (stackTop > stackBase) { - edge = edgeStack[--stackTop]!; - promote = resumePromote = promoteStack[stackTop]!; + if (nextState !== 0) { + sub.state = nextState; + if (__DEV__) recordPropagation(edge, nextState, promoteBit, context); + + if ((nextState & ReactiveNodeState.Watcher) !== 0) { + thrown = notifyWatcherInvalidation(sub, thrown, context); } else { - return thrown; + const firstOut = sub.firstOut; + if (firstOut !== null) { + if (resume !== null) { + edgeStack[stackTop] = resume; + promoteStack[stackTop++] = resumePromote; + } + + edge = firstOut; + resume = firstOut.nextOut; + promoteBit = resumePromote = NON_IMMEDIATE; + continue; + } } + } - resume = edge.nextOut; + if (resume !== null) { + edge = resume; + promoteBit = resumePromote; + } else if (stackTop > stackBase) { + edge = edgeStack[--stackTop]!; + promoteBit = resumePromote = promoteStack[stackTop]!; + } else { + edgeStack.length = promoteStack.length = stackBase; + return thrown; } - } finally { - edgeStack.length = promoteStack.length = stackBase; + + resume = edge.nextOut; } } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts index 91cf677..b33dae2 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.constants.ts @@ -1,18 +1,22 @@ -import { DIRTY_STATE, ReactiveNodeState, WALKER_STATE } from "../shape"; +import { ReactiveNodeState } from "../shape"; /** * NON_IMMEDIATE flag: somwhere in the middle subscribers are promoted Invalid. - * + * * This tells them "maybe changed, verify dependencies and then recompute" */ -export const NON_IMMEDIATE = 0; +export const NON_IMMEDIATE = ReactiveNodeState.Invalid; /** * IMMEDIATE flag: direct subscribers are promoted from Invalid → Changed. - * + * * This tells them "definitely changed, don't verify, recompute" */ -export const IMMEDIATE = 1; +export const IMMEDIATE = ReactiveNodeState.Changed; -export const INVALIDATION_SLOW_PATH_MASK = - DIRTY_STATE | ReactiveNodeState.Disposed | WALKER_STATE; +export const CAN_ESCAPE_INVALIDATION = + ReactiveNodeState.Invalid | + ReactiveNodeState.Changed | + ReactiveNodeState.Disposed | + ReactiveNodeState.Visited | + ReactiveNodeState.Tracking; diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagate.utils.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagate.utils.ts index 933b939..1e87013 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagate.utils.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagate.utils.ts @@ -24,15 +24,12 @@ export function isTrackedPrefixEdge( export function getSlowInvalidatedSubscriberState( edge: ReactiveEdge, state: number, - promoteImmediate: number, + promoteBit: number, ): number { if ((state & (DIRTY_STATE | ReactiveNodeState.Disposed)) !== 0) return 0; if ((state & ReactiveNodeState.Tracking) === 0) { - return ( - (state & ~ReactiveNodeState.Visited) | - (promoteImmediate ? ReactiveNodeState.Changed : ReactiveNodeState.Invalid) - ); + return (state & ~ReactiveNodeState.Visited) | promoteBit; } return isTrackedPrefixEdge(edge, edge.to.depsTail) diff --git a/packages/@reflex/runtime/src/reactivity/walkers/propagation.watchers.ts b/packages/@reflex/runtime/src/reactivity/walkers/propagation.watchers.ts index b8b74c8..dcfe31a 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/propagation.watchers.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/propagation.watchers.ts @@ -1,6 +1,7 @@ import { recordDebugEvent } from "../../debug"; import type { ExecutionContext } from "../context"; import type { ReactiveEdge, ReactiveNode } from "../shape"; +import { IMMEDIATE } from "./propagate.constants"; // ─── recordPropagation ──────────────────────────────────────────────────────── // @@ -10,11 +11,11 @@ import type { ReactiveEdge, ReactiveNode } from "../shape"; export function recordPropagation( edge: ReactiveEdge, nextState: number, - promote: number, + promoteBit: number, context: ExecutionContext, ): void { recordDebugEvent(context, "propagate", { - detail: { immediate: promote !== 0, nextState }, + detail: { immediate: promoteBit === IMMEDIATE, nextState }, source: edge.from, target: edge.to, }); diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts index 7a4fe05..d2438ef 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branch.ts @@ -17,8 +17,7 @@ import type { ExecutionContext } from "../context"; import type { ReactiveNode, ReactiveEdge } from "../shape"; import { ReactiveNodeState, DIRTY_STATE } from "../shape"; import { shouldRecomputeBranching } from "./recompute.branching"; -import { refreshProducer, refreshRecompute } from "./recompute.refresh"; - +import { refreshRecompute } from "./recompute.refresh"; // Shared stack — reused across calls to avoid allocation. // stackBase tracks the logical bottom per call so recursive entries @@ -61,17 +60,12 @@ export function shouldRecomputeLinear( const depState = dep.state; if ((depState & ReactiveNodeState.Changed) !== 0) { - changed = - (depState & ReactiveNodeState.Producer) !== 0 - ? refreshProducer(dep, depState) - : refreshRecompute(link, dep, context); - break; + changed = refreshRecompute(link, dep, context); + + if (changed || link.nextIn === null) break; } - if ( - (depState & ReactiveNodeState.Producer) === 0 && - (depState & DIRTY_STATE) !== 0 - ) { + if ((depState & DIRTY_STATE) !== 0) { const deps = dep.firstIn; if (deps !== null) { if (deps.nextIn !== null) { @@ -103,11 +97,11 @@ export function shouldRecomputeLinear( break; } - // dep is clean or Producer with no change flag: mark consumer clean. + // dep is clean: mark consumer clean too. consumer.state &= ~ReactiveNodeState.Invalid; if (stackTop === stackBase) { - // Stack empty: nothing changed anywhere. + // Stack empty: nothing changed anymore. return false; } @@ -120,10 +114,7 @@ export function shouldRecomputeLinear( const parentLink = stack[--stackTop]!; if (changed) { - changed = - (consumer.state & ReactiveNodeState.Producer) !== 0 - ? refreshProducer(consumer, consumer.state) - : refreshRecompute(parentLink, consumer, context); + changed = refreshRecompute(parentLink, consumer, context); } else { consumer.state &= ~ReactiveNodeState.Invalid; } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts index 7db8012..0262677 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.branching.ts @@ -16,7 +16,7 @@ import { ReactiveNodeState, DIRTY_STATE, } from "../shape"; -import { refreshProducer, refreshRecompute } from "./recompute.refresh"; +import { refreshRecompute } from "./recompute.refresh"; export function shouldRecomputeBranching( link: ReactiveEdge, @@ -35,15 +35,9 @@ export function shouldRecomputeBranching( if ((consumer.state & ReactiveNodeState.Changed) !== 0) { changed = true; } else if ((depState & ReactiveNodeState.Changed) !== 0) { - // Producer or already-confirmed computed: pick the cheap path. - changed = - (depState & ReactiveNodeState.Producer) !== 0 - ? refreshProducer(dep, depState) - : refreshRecompute(link, dep, context); - } else if ( - (depState & ReactiveNodeState.Producer) === 0 && - (depState & DIRTY_STATE) !== 0 - ) { + // Already-confirmed computed dependency: refresh and stop searching. + changed = refreshRecompute(link, dep, context); + } else if ((depState & DIRTY_STATE) !== 0) { const deps = dep.firstIn; if (deps !== null) { stack[stackTop++] = link; @@ -67,10 +61,7 @@ export function shouldRecomputeBranching( const parentLink = stack[--stackTop]!; if (changed) { - changed = - (consumer.state & ReactiveNodeState.Producer) !== 0 - ? refreshProducer(consumer, consumer.state) - : refreshRecompute(parentLink, consumer, context); + changed = refreshRecompute(parentLink, consumer, context); } else { consumer.state &= ~ReactiveNodeState.Invalid; } diff --git a/packages/@reflex/runtime/src/reactivity/walkers/recompute.refresh.ts b/packages/@reflex/runtime/src/reactivity/walkers/recompute.refresh.ts index 78445cc..feaa327 100644 --- a/packages/@reflex/runtime/src/reactivity/walkers/recompute.refresh.ts +++ b/packages/@reflex/runtime/src/reactivity/walkers/recompute.refresh.ts @@ -1,32 +1,14 @@ // ─── refreshDependency ──────────────────────────────────────────────────────── // -// Split into two functions to keep each call site monomorphic and small enough -// for JIT inlining: -// -// refreshProducer — only Producer nodes, no recompute, no propagate -// refreshRecompute — only Consumer nodes, calls recompute + maybe propagateOnce -// -// Why split vs one function with a branch? -// -// A single refreshDependency(link, node, context, state) had 5 call sites -// receiving both Producer and Consumer nodes → polymorphic dispatch on every -// call → TurboFan/Ion/DFG back off from inlining after 4+ distinct receiver -// shapes. Two dedicated functions keep each call site strictly monomorphic. -// -// Size budget: each fits in ~10 AST nodes → all three JITs will inline them. +// Pull-walk only refreshes computed nodes. Producers commit on write and should +// never reach shouldRecompute() with dirty bits set, so this helper stays +// monomorphic and tiny enough for JIT inlining at every call site. import type { ExecutionContext } from "../context"; import { recompute } from "../engine"; import type { ReactiveNode, ReactiveEdge } from "../shape"; -import { DIRTY_STATE, ReactiveNodeState } from "../shape"; import { propagateOnce } from "./propagate.once"; -/** Refresh a Producer node. Returns true if its value changed. */ -export function refreshProducer(node: ReactiveNode, state: number): boolean { - node.state = state & ~DIRTY_STATE; - return (state & ReactiveNodeState.Changed) !== 0; -} - /** * Refresh a Computed node and propagate sideways if it has fanout. * Returns true if its value changed. diff --git a/packages/@reflex/runtime/tests/runtime.walkers.test.ts b/packages/@reflex/runtime/tests/runtime.walkers.test.ts index 1c5a959..5e3c100 100644 --- a/packages/@reflex/runtime/tests/runtime.walkers.test.ts +++ b/packages/@reflex/runtime/tests/runtime.walkers.test.ts @@ -58,6 +58,36 @@ describe("Reactive runtime - walker invariants", () => { ); }); + it("can mark the whole reachable graph Changed when every subscriber is direct", () => { + const source = createNode(ReactiveNodeState.Producer); + const left = createNode(ReactiveNodeState.Consumer); + const right = createNode(ReactiveNodeState.Consumer); + const watcher = createNode(ReactiveNodeState.Watcher); + const invalidated: ReactiveNode[] = []; + const context = createTestContext({ + onEffectInvalidated(node) { + invalidated.push(node); + }, + }); + + linkEdge(source, left); + linkEdge(source, right); + linkEdge(source, watcher); + + propagate(source.firstOut!, IMMEDIATE, context); + + expect(left.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Changed, + ); + expect(right.state).toBe( + ReactiveNodeState.Consumer | ReactiveNodeState.Changed, + ); + expect(watcher.state).toBe( + ReactiveNodeState.Watcher | ReactiveNodeState.Changed, + ); + expect(invalidated).toEqual([watcher]); + }); + it("propagate skips disposed subtrees without aborting sibling traversal", () => { const source = createNode(ReactiveNodeState.Producer); const disposed = createNode( @@ -104,6 +134,26 @@ describe("Reactive runtime - walker invariants", () => { ); }); + it("keeps transitive slow-path subscribers Invalid when only Visited is set", () => { + const source = createNode(ReactiveNodeState.Producer); + const middle = createNode(ReactiveNodeState.Consumer); + const leaf = createNode( + ReactiveNodeState.Consumer | ReactiveNodeState.Visited, + ); + + linkEdge(source, middle); + linkEdge(middle, leaf); + + propagate(source.firstOut!, IMMEDIATE, createTestContext()); + + 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( From 6080529346344d509ad240fb38eca7eac2011242 Mon Sep 17 00:00:00 2001 From: Andrii Volynets Date: Thu, 2 Apr 2026 14:08:32 +0300 Subject: [PATCH 3/3] refactor: update package names and versions for @volynets/reflex and @reflex/runtime --- Readme.md | 24 +++++++++++++----------- packages/@reflex/runtime/package.json | 2 +- packages/reflex/README.md | 19 +++++++++---------- packages/reflex/package.json | 4 ++-- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/Readme.md b/Readme.md index 770419d..fbb4a80 100644 --- a/Readme.md +++ b/Readme.md @@ -22,7 +22,7 @@ Runtime layer built on top of `@reflex/core`. - reactive execution helpers - scheduler-oriented runtime APIs -### `@reflex/reactive` +### `@volynets/reflex` Public application-facing facade. @@ -30,26 +30,28 @@ Public application-facing facade. - `computed` - `memo` - `effect` -- `flush` -- `batchWrite` - `createRuntime` +- `map` / `filter` / `merge` +- `scan` / `hold` / `subscribeOnce` ## Recommended Entry Point -For application code, start with `@reflex/reactive`. +For application code, start with `@volynets/reflex`. ```ts -import { signal, computed, effect, flush } from "@reflex/reactive"; +import { signal, computed, effect, createRuntime } from "@volynets/reflex"; -const count = signal(0); -const double = computed(() => count.read() * 2); +const rt = createRuntime(); + +const [count, setCount] = signal(0); +const double = computed(() => count() * 2); effect(() => { - console.log(count.read(), double()); + console.log(count(), double()); }); -count.write(5); -flush(); +setCount(5); +rt.flush(); ``` ## Architecture @@ -58,7 +60,7 @@ The repository is currently organized around three active layers: 1. `@reflex/core` 2. `@reflex/runtime` -3. `@reflex/reactive` +3. `@volynets/reflex` There is also a DOM adapter in the repository as `reflex-dom`. diff --git a/packages/@reflex/runtime/package.json b/packages/@reflex/runtime/package.json index d29285e..65836ca 100644 --- a/packages/@reflex/runtime/package.json +++ b/packages/@reflex/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@reflex/runtime", - "version": "1.1.0", + "version": "0.1.0", "type": "module", "description": "Runtime / Universe / Laws for Reflex", "license": "MIT", diff --git a/packages/reflex/README.md b/packages/reflex/README.md index f37050c..2387062 100644 --- a/packages/reflex/README.md +++ b/packages/reflex/README.md @@ -1,7 +1,7 @@ -# `@volynetstyle/reflex` +# `@volynets/reflex` -[![npm version](https://img.shields.io/npm/v/%40volynetstyle%2Freflex?logo=npm)](https://www.npmjs.com/package/@volynetstyle/reflex) -[![npm downloads](https://img.shields.io/npm/dm/%40volynetstyle%2Freflex?logo=npm)](https://www.npmjs.com/package/@volynetstyle/reflex) +[![npm version](https://img.shields.io/npm/v/%40volynets%2Freflex?logo=npm)](https://www.npmjs.com/package/@volynets/reflex) +[![npm downloads](https://img.shields.io/npm/dm/%40volynets%2Freflex?logo=npm)](https://www.npmjs.com/package/@volynets/reflex) [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/volynetstyle/Reflex/blob/main/packages/reflex/LICENSE) [![typed with TypeScript](https://img.shields.io/badge/typed-TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![runtime: Reflex](https://img.shields.io/badge/runtime-Reflex-111827)](https://github.com/volynetstyle/Reflex) @@ -12,7 +12,7 @@ Small signal-style reactivity on top of the Reflex runtime. -`@volynetstyle/reflex` is the product-facing API for building reactive state, derived values, effects, and event-driven state without dropping down to the lower-level runtime primitives. +`@volynets/reflex` is the product-facing API for building reactive state, derived values, effects, and event-driven state without dropping down to the lower-level runtime primitives. It gives you: @@ -24,18 +24,17 @@ It gives you: Under the hood it is built on: - [`@reflex/runtime`](https://github.com/volynetstyle/Reflex/tree/main/packages/%40reflex/runtime) for reactive execution -- [`@reflex/core`](https://github.com/volynetstyle/Reflex/tree/main/packages/%40reflex/core) for lower-level infrastructure ## Install ```bash -npm install @volynetstyle/reflex +npm install @volynets/reflex ``` ## Quick Start ```ts -import { computed, createRuntime, effect, signal } from "@volynetstyle/reflex"; +import { computed, createRuntime, effect, signal } from "@volynets/reflex"; const rt = createRuntime(); @@ -83,7 +82,7 @@ The top-level primitives are not methods on `rt`, but they are still runtime-bac ### Signals and derived values ```ts -import { computed, createRuntime, memo, signal } from "@volynetstyle/reflex"; +import { computed, createRuntime, memo, signal } from "@volynets/reflex"; createRuntime(); @@ -106,7 +105,7 @@ console.log(warmed()); // 290 ### Events and accumulated state ```ts -import { computed, createRuntime, effect, hold, scan } from "@volynetstyle/reflex"; +import { computed, createRuntime, effect, hold, scan } from "@volynets/reflex"; const rt = createRuntime(); const updates = rt.event(); @@ -141,7 +140,7 @@ import { map, merge, subscribeOnce, -} from "@volynetstyle/reflex"; +} from "@volynets/reflex"; const rt = createRuntime(); const clicks = rt.event(); diff --git a/packages/reflex/package.json b/packages/reflex/package.json index 5244205..7675742 100644 --- a/packages/reflex/package.json +++ b/packages/reflex/package.json @@ -1,6 +1,6 @@ { - "name": "@volynetstyle/reflex", - "version": "1.4.1", + "name": "@volynets/reflex", + "version": "0.1.1", "type": "module", "description": "Public Reflex facade with a connected runtime", "license": "MIT",