From bcc2fd285e55e679e2833d4c1d4012fcd8751bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:17:37 +0000 Subject: [PATCH 1/2] perf(signals,web): get the hello world back under 10 KB Optional engines install their scheduler hooks as module-level variables instead of GlobalQueue statics, so the minifier folds their paths away in bundles that never load them. Lane- and verdict-only paths in recompute, read, setSignal and the settle step are gated on those hooks. On the web side, client-only render drops hydration text adoption, eventHandler drops guards no caller needs, and reconcileArrays reuses isLive. compiled floor 10,352 -> 9,752 B brotli; render + one signal 10,171 -> 9,549 B; signals core floor 7,657 -> 7,238 B. Caps ratcheted down. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F58uQgS9taZV2yd3SW4srW --- .changeset/hello-world-under-10kb.md | 6 + packages/signals/src/affects.ts | 13 +- packages/signals/src/boundaries.ts | 33 +-- packages/signals/src/core/async.ts | 9 +- packages/signals/src/core/constants.ts | 2 +- packages/signals/src/core/core.ts | 93 ++++--- packages/signals/src/core/effect.ts | 7 +- packages/signals/src/core/external.ts | 8 +- packages/signals/src/core/lanes.ts | 25 +- packages/signals/src/core/scheduler.ts | 232 ++++++++++++------ packages/signals/src/core/verdict.ts | 5 +- packages/signals/src/store/affects.ts | 4 +- packages/signals/src/store/store.ts | 11 +- .../loading-fallback-in-flush-3540.test.ts | 2 +- packages/web/src/client.ts | 32 +-- packages/web/src/reconcile.ts | 54 ++-- scripts/size/floor-caps.json | 16 +- scripts/size/scenarios.js | 115 +++++++-- 18 files changed, 418 insertions(+), 249 deletions(-) create mode 100644 .changeset/hello-world-under-10kb.md diff --git a/.changeset/hello-world-under-10kb.md b/.changeset/hello-world-under-10kb.md new file mode 100644 index 0000000000..91aa98e14d --- /dev/null +++ b/.changeset/hello-world-under-10kb.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Smaller floor bundles: the optional engines (lanes, verdicts, boundaries, stores, affects, external sources) install their scheduler hooks as module variables, so an app that never loads them no longer retains their paths in `recompute`, `read` and the settle step; client-only `render` no longer retains hydration text adoption, and the delegated dispatcher drops dead guards. The JSX hello world goes from 10.35 KB to 9.75 KB brotli. diff --git a/packages/signals/src/affects.ts b/packages/signals/src/affects.ts index 1d5c146a30..2f5e67b63a 100644 --- a/packages/signals/src/affects.ts +++ b/packages/signals/src/affects.ts @@ -21,7 +21,13 @@ import { STATUS_PENDING, CONFIG_SLOT_NODE, $REFRESH } from "./core/constants.js"; import { emitDiagnostic } from "./core/dev.js"; -import { flushTransaction, GlobalQueue, resolveTx, schedule } from "./core/scheduler.js"; +import { + flushTransaction, + GlobalQueue, + resolveTx, + schedule, + installMarkHooks +} from "./core/scheduler.js"; import type { Computed } from "./core/types.js"; import { mark, repoll, unmark, type Marked } from "./affects-marks.js"; import type { Accessor } from "./signals.js"; @@ -122,8 +128,7 @@ function invalid(detail: string): never { throw new Error(message); } -GlobalQueue._releaseMarks = release; -GlobalQueue._releaseAmbientMarks = parked => { +installMarkHooks(release, parked => { if (ambient.length === 0) return; // The flush parked: the declaration's window is the frame's — the marks // are its transaction's ("nothing async below ⇒ no window"). @@ -146,7 +151,7 @@ GlobalQueue._releaseAmbientMarks = parked => { } ambient.length = kept; if (done.length !== 0) release(done); -}; +}); function inFlight(n: Marked): boolean { return ( ((n as Computed)._statusFlags & STATUS_PENDING) !== 0 || diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 4ad0315079..2b523be8db 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -77,7 +77,9 @@ import { setPassLane, txOf, unhold, - type Transaction + type Transaction, + installBoundaryHooks, + laneStageHook } from "./core/scheduler.js"; import { attrHooks } from "./core/attribution-hooks.js"; import type { Computed, Owner, Signal } from "./core/types.js"; @@ -203,8 +205,7 @@ function flip(b: Boundary): void { b._lane = b._armLane; const o = b._output!; if (!b._lane && o._config & CONFIG_HELD && txOf(o) !== flushTransaction) unhold(o); - if (b._lane !== null && b._output !== null) - GlobalQueue._laneStage!(b._output, b._lane, true, false); + if (b._lane !== null && b._output !== null) laneStageHook!(b._output, b._lane, true, false); } export function redraw(b: Boundary): void { @@ -214,7 +215,7 @@ export function redraw(b: Boundary): void { } } -/** GlobalQueue._catch: status from a frame reader, nearest boundary first. +/** `catchHook` (scheduler.ts): status from a frame reader, nearest boundary first. * A loading boundary on the way records a pending reader whether or not it * catches it (its `on` may collect it later). */ function catchStatus( @@ -261,14 +262,14 @@ function armedOver(node: Computed): boolean | undefined { } /** The nearest boundary on `r`'s chain showing its fallback, if any - * (GlobalQueue._hidden: `r` is behind a fallback). */ + * (`hiddenHook`: `r` is behind a fallback). */ function hiddenBy(r: Computed): Boundary | undefined { for (let b: Boundary | null | undefined = boundaryOf(r); b; b = b._parent) if (b._fallback) return b; return undefined; } -/** GlobalQueue._heldRun: a queued run behind a fallback waits for the reveal +/** `heldRunHook` (scheduler.ts): a queued run behind a fallback waits for the reveal * (maintainer, 2026-10-02: the queue is held, the synchronous first render * goes through). A user effect's, which would read a DOM that is not * attached ("many depend on reading the DOM, something that can't happen @@ -352,7 +353,7 @@ export function ready(b: Boundary): boolean { ); } -/** GlobalQueue._boundarySeam, end of the seam: readers that settled, died, +/** `boundarySeamHook` (scheduler.ts), end of the seam: readers that settled, died, * or landed and committed are dropped; a fallback with none left reveals * next round. An arm resolves: nothing collected, nothing happened. */ function boundarySeam(): void { @@ -848,10 +849,14 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. -GlobalQueue._catch = catchStatus; -GlobalQueue._fresh = (node, held) => - !held && - (node._statusFlags & STATUS_UNINITIALIZED ? catchStatus(node, STATUS_PENDING) : armedOver(node)); -GlobalQueue._hidden = hiddenBy; -GlobalQueue._boundarySeam = boundarySeam; -GlobalQueue._heldRun = heldRun; +installBoundaryHooks( + catchStatus, + (node, held) => + !held && + (node._statusFlags & STATUS_UNINITIALIZED + ? catchStatus(node, STATUS_PENDING) + : armedOver(node)), + hiddenBy, + boundarySeam, + heldRun +); diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index f448a9c911..9dd92492c2 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -32,14 +32,15 @@ import { flush, flushTransaction, globalQueue, - GlobalQueue, joinFuture, MAINLINE_QUESTION, passLane, question, queuePendingNode, schedule, - txOf + txOf, + freshHook, + laneStageHook } from "./scheduler.js"; import type { Computed, Link } from "./types.js"; @@ -955,7 +956,7 @@ export function propagateStatus( passLane === null && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && globalQueue._running && - !GlobalQueue._fresh?.(sub, pendingSource._time === clock) + !freshHook?.(sub, pendingSource._time === clock) ) joinFuture(txOf(sub)); return; @@ -970,7 +971,7 @@ export function propagateStatus( // the dependent's pending is the lane's own flight (`laneStage`). if (!downstreamBlockStatus) { // (No answer: a pending propagation leaves a written guess a guess.) - if (passLane !== null) GlobalQueue._laneStage!(sub, passLane, true, el); + if (laneStageHook && passLane !== null) laneStageHook(sub, passLane, true, el); else { queuePendingNode(sub); // A15 (#3443): pending propagates onto a held memo without diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index c918d537d8..b40b9d7d41 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -206,7 +206,7 @@ export const STATUS_PENDING = 1 << 0; export const STATUS_ERROR = 1 << 1; export const STATUS_UNINITIALIZED = 1 << 2; -/** `GlobalQueue._catch`'s answer when the catcher is a boundary re-armed by +/** `catchHook`'s answer when the catcher is a boundary re-armed by * `on` (truthy, as any catch): what it collects is its own, not the hold's. */ export const CAUGHT_REARMED = 2; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 332ebd56e1..1d1f87a1f1 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -114,7 +114,16 @@ import { joinPassTx, stagedReaders, staleReader, - laneDirty + laneDirty, + externalUntrackHook, + freshHook, + laneOutcomeHook, + laneReadHook, + laneStageHook, + laneSeamsHook, + laneWriteHook, + observeFlightHook, + wireExternalSourceHook } from "./scheduler.js"; import type { Transaction } from "./scheduler.js"; import type { @@ -274,18 +283,22 @@ export function recompute(el: Computed, create: boolean = false): void { // derivation's pass into the lane (`read`); a leaf's never moves. // Restored at the end, after this pass's staging and runs have been // routed. + // Lanes exist only with the lane engine installed (`laneStageHook`): a + // bundle without it seats every pass on mainline, and folds the seat away. const prevLane = passLane; setPassLane( - (el._flags & REACTIVE_LANE_DIRTY || - (el._config & (CONFIG_OVERRIDE | CONFIG_GUESS)) === CONFIG_OVERRIDE) && - el._x?._transaction?._lane - ? el._x._transaction - : // Loose `!=`: false with no creator lane (undefined == null). - create && - prevLane?._parent!._verdict != prevLane && - (creatorPass(context)?._flags as number) & REACTIVE_RECOMPUTING_DEPS - ? prevLane - : null + !laneStageHook + ? null + : (el._flags & REACTIVE_LANE_DIRTY || + (el._config & (CONFIG_OVERRIDE | CONFIG_GUESS)) === CONFIG_OVERRIDE) && + el._x?._transaction?._lane + ? el._x._transaction + : // Loose `!=`: false with no creator lane (undefined == null). + create && + prevLane?._parent!._verdict != prevLane && + (creatorPass(context)?._flags as number) & REACTIVE_RECOMPUTING_DEPS + ? prevLane + : null ); // Attribution hook: fired before this run touches the dep list — `_deps` // still holds the previous run's links (the subscriptions that could have @@ -331,7 +344,7 @@ export function recompute(el: Computed, create: boolean = false): void { // it joins nothing, so the write that reached it commits mainline. if (tx._lane || el._config & CONFIG_LAGS) { } else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) - joinPassTx(tx, GlobalQueue._fresh?.(el, el._statusFlags & STATUS_UNINITIALIZED)); + joinPassTx(tx, freshHook?.(el, el._statusFlags & STATUS_UNINITIALIZED)); else if ( tx !== flushTransaction && !(el._statusFlags & STATUS_UNINITIALIZED || el._config & CONFIG_HELD_FRAME) @@ -536,12 +549,12 @@ export function recompute(el: Computed, create: boolean = false): void { // seat that read none of the lane's world has left it: its result is the // frame's (a derivation whose branch no longer reaches the guess). A // guess is written, not derived — it never leaves this way. - let lane = passLane; + let lane = !laneStageHook ? null : passLane; // Listed before its staging, a pending pass included (the lane's own // flight is the lane's); false: the pass left the lane (lanes.ts). const errored = !!el._x?._error || stagedErr; - if (lane !== null) { - if (GlobalQueue._laneStage!(el, lane, create, errored)) setPassLane(lane); + if (laneStageHook && lane !== null) { + if (laneStageHook(el, lane, create, errored)) setPassLane(lane); else lane = null; } @@ -636,7 +649,7 @@ export function recompute(el: Computed, create: boolean = false): void { !create && lane === null && el._config & CONFIG_GUESS && - GlobalQueue._laneOutcome!(el, value, errored) + laneOutcomeHook!(el, value, errored) ) { // The truth, staged under the lane's parent. } else if (errored) { @@ -709,7 +722,7 @@ export function recompute(el: Computed, create: boolean = false): void { // #3851): the mount is part of the hold, and the boundary appears // at its commit, its fallback never seen (D-13, U-3) — a `` // mounting it over a landing included. - GlobalQueue._fresh?.(el, !prevLane && flushTransaction); + freshHook?.(el, !prevLane && flushTransaction); } if (__DEV__) devTrackHeldPending(el); } @@ -760,7 +773,7 @@ export function recompute(el: Computed, create: boolean = false): void { el._statusFlags & STATUS_PENDING ) { const t = flushTransaction ?? passTx; - if (t && !t._lane && GlobalQueue._fresh?.(el) !== CAUGHT_REARMED) holdNode(el, t); + if (t && !t._lane && freshHook?.(el) !== CAUGHT_REARMED) holdNode(el, t); } // Dependencies are the committed frame's until it is replaced (A30, #3410): // a pass that staged its value leaves the previous pass's tail linked for @@ -1033,7 +1046,7 @@ export function joinPass(c: Computed, el: Signal | Computed): voi ) joinPassTx( txOf(el), - (!(c as any)._type && c._statusFlags & STATUS_UNINITIALIZED) || GlobalQueue._fresh?.(c) + (!(c as any)._type && c._statusFlags & STATUS_UNINITIALIZED) || freshHook?.(c) ); } @@ -1072,9 +1085,11 @@ function frameRead(c: Computed, el: Signal | Computed): boolean { // read the screen. Lane work with no committed value yet reads a flight // as a mount's memo does: it enters, and the boundary it mounts catches // the pending (#3540). - const verdict = c._config & CONFIG_VERDICT; + // (Verdict readers and lanes exist only with their engines installed.) + const verdict = observeFlightHook && c._config & CONFIG_VERDICT; + const lane = laneStageHook && passLane !== null; if ( - (passLane === null || + (!lane || (c._statusFlags & STATUS_UNINITIALIZED && (el as Computed)._statusFlags & STATUS_PENDING)) && !verdict && @@ -1085,7 +1100,7 @@ function frameRead(c: Computed, el: Signal | Computed): boolean { // Lane work and verdict readers are never the transaction's pass, whichever // flush runs it: a node T holds re-enters T when it re-runs (`recompute`'s // head), and the pass may then read a guess and become the lane's. - if ((t === flushTransaction && passLane === null && !verdict) || bornInto(c, t)) return false; + if ((t === flushTransaction && !lane && !verdict) || bornInto(c, t)) return false; staleReader(c, t); return true; } @@ -1284,7 +1299,7 @@ function setupComputedNode(self: Computed, options: NodeOptions | undef if (context) linkChild(context, self); if (__DEV__) DEV.hooks.onOwner?.(self); if (parent) self._height = parent._height + 1; - GlobalQueue._wireExternalSource?.(self); + wireExternalSourceHook?.(self); !options?.lazy && recompute(self, true); if (snapshotCaptureActive && !options?.lazy) { if (!(self._statusFlags & STATUS_PENDING) && !(self._config & CONFIG_NO_SNAPSHOT)) { @@ -1480,12 +1495,6 @@ export function setStrictRead(v: string | false): string | false { * ``` */ export function untrack(fn: () => T, strictReadLabel?: string | false): T { - if ( - GlobalQueue._externalUntrack === null && - !tracking && - (!__DEV__ || (!strictRead && !strictReadLabel && asyncTailFlights === 0)) - ) - return fn(); const prevTracking = tracking; const prevStrictRead = strictRead; tracking = false; @@ -1494,7 +1503,7 @@ export function untrack(fn: () => T, strictReadLabel?: string | false): T { untrackDepth++; } try { - if (GlobalQueue._externalUntrack) return GlobalQueue._externalUntrack(fn); + if (externalUntrackHook) return externalUntrackHook(fn); return fn(); } finally { tracking = prevTracking; @@ -1728,7 +1737,8 @@ export function read(el: Signal | Computed): T { if (!c || el._pendingValue === NOT_PENDING || c._config & CONFIG_CHILDREN_FORBIDDEN) return el._value as T; // (A plain signal is never uninitialized — the carve-out is `serve`'s.) - if (c._config & CONFIG_VERDICT && stagedScreen(c as Computed)) return el._value as T; + if (observeFlightHook && c._config & CONFIG_VERDICT && stagedScreen(c as Computed)) + return el._value as T; stagedRead(c as Computed); return el._pendingValue as T; } @@ -1772,8 +1782,9 @@ export function read(el: Signal | Computed): T { } // Lanes: a lane's node (after the pull — the node is current). NOT_PENDING // falls through: lane work's read of a pending member throws like any. - if (el._config & CONFIG_OVERRIDE) { - const v = GlobalQueue._laneRead!(c as Computed | null, el); + // (CONFIG_OVERRIDE is the lane engine's: no engine, no lane's node.) + if (laneReadHook && el._config & CONFIG_OVERRIDE) { + const v = laneReadHook(c as Computed | null, el); if (v !== NOT_PENDING) return v as T; } @@ -1799,10 +1810,15 @@ export function read(el: Signal | Computed): T { ) { // A reader with no committed value yet sees the flight pending: a // boundary a lane mounts shows its fallback (A29's boundary exemption). - if (passLane !== null && !((c as Computed | null)?._statusFlags! & STATUS_UNINITIALIZED)) + // (A lane, and a verdict reader, exist only with their engines installed.) + if ( + laneStageHook && + passLane !== null && + !((c as Computed | null)?._statusFlags! & STATUS_UNINITIALIZED) + ) committed = true; - else if (c !== null && c._config & CONFIG_VERDICT) - committed = GlobalQueue._observeFlight!(c as Computed, owner); + else if (observeFlightHook && c !== null && c._config & CONFIG_VERDICT) + committed = observeFlightHook(c as Computed, owner); } if (owner._statusFlags & STATUS_PENDING && !committed) { // A reader landing on a pending node throws; an untracked read of an @@ -1925,6 +1941,7 @@ export function serve(el: Signal | Computed, c: Computed | null): } if (readerSeesCommitted(el, c)) return el._value; if ( + observeFlightHook && c!._config & CONFIG_VERDICT && !((el as Computed)._statusFlags & STATUS_UNINITIALIZED) && stagedScreen(c!) @@ -1951,7 +1968,7 @@ export function serve(el: Signal | Computed, c: Computed | null): export function stagedRead(c: Computed, el?: Signal | Computed): void { if (!((el as Computed | undefined)?._statusFlags! & STATUS_UNINITIALIZED)) { c._flags |= REACTIVE_STAGED_READ; - if (passLane !== null) stagedReaders.push(c); + if (laneSeamsHook && passLane !== null) stagedReaders.push(c); } } @@ -2033,7 +2050,7 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T // Lanes: a write landing on a guess is its truth (A18 — an async guess's // own landing), judged against the guess; one landing on a lane's // derivation (its flight) is the lane's staging (lanes.ts). - if (el._config & CONFIG_OVERRIDE) return GlobalQueue._laneWrite!(el, v); + if (laneWriteHook && el._config & CONFIG_OVERRIDE) return laneWriteHook(el, v); // L2 (T4) / A34 (1): a write to a node whose staged state is held is a // second proposal for the slot — the same value again or another — and // the two cannot finish at different times: the writer's tick joins the diff --git a/packages/signals/src/core/effect.ts b/packages/signals/src/core/effect.ts index e519d9ede7..fa0715b210 100644 --- a/packages/signals/src/core/effect.ts +++ b/packages/signals/src/core/effect.ts @@ -33,7 +33,8 @@ import { resetUnhandledAsync, schedule, setTrackedQueueCallback, - setEffectCallback + setEffectCallback, + heldRunHook } from "./scheduler.js"; import type { Computed, NodeOptions, Owner } from "./types.js"; @@ -159,7 +160,7 @@ function runEffect(node: Effect, type: number): void { // hidden subtree. The synchronous first render on creation is not a queued // run and goes through. `_modified` stays set; the boundary re-queues the // run (`release`). - if (GlobalQueue._heldRun?.(node)) return; + if (heldRunHook?.(node)) return; // Error arm (#2840), user effects only: a compute-phase error that is still // the node's settled state at effect time runs the bundle's error handler in // this same imperative, writable scope. Unwrap the StatusError used for @@ -262,7 +263,7 @@ export function trackedEffect(fn: () => void | (() => void), options?: NodeOptio // pass, but several passes in one flush each enqueue `_run` into the same // user queue, and this gate is what collapses them into one run. if (!node._modified || node._flags & REACTIVE_DISPOSED) return; - if (GlobalQueue._heldRun?.(node)) return; + if (heldRunHook?.(node)) return; if (__DEV__) setTrackedQueueCallback(true); try { node._modified = false; diff --git a/packages/signals/src/core/external.ts b/packages/signals/src/core/external.ts index b344b952d6..c082cc7e1c 100644 --- a/packages/signals/src/core/external.ts +++ b/packages/signals/src/core/external.ts @@ -1,6 +1,6 @@ import { read, setSignal, signal } from "./core.js"; import { cleanup } from "./owner.js"; -import { GlobalQueue } from "./scheduler.js"; +import { setExternalHooks } from "./scheduler.js"; import type { Computed } from "./types.js"; export type ExternalSourceFactory = (fn: (prev: any) => any, trigger: () => void) => ExternalSource; @@ -51,7 +51,7 @@ export let externalSourceConfig: { * ``` */ // Wires a freshly created computed through the active external-source bridge. -// Lives here (installed on GlobalQueue while a config is active) rather than +// Lives here (installed as a scheduler hook while a config is active) rather than // inline in core: esbuild cannot literal-track the mutable config binding the // way rollup does, so an inline `if (externalSourceConfig)` block ships in // every bundle even though only enableExternalSource() can make it reachable. @@ -75,8 +75,8 @@ function externalUntrack(fn: () => T): T { // removed on reset) so core's presence checks stay equivalent to the old // `externalSourceConfig` truthiness checks. function syncExternalHooks(): void { - GlobalQueue._wireExternalSource = externalSourceConfig ? wireExternalSource : undefined; - GlobalQueue._externalUntrack = externalSourceConfig ? externalUntrack : undefined; + if (externalSourceConfig) setExternalHooks(wireExternalSource, externalUntrack); + else setExternalHooks(undefined, undefined); } export function enableExternalSource(config: ExternalSourceConfig): void { diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index cb7d870236..5317787513 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -87,7 +87,8 @@ import { schedule, setPassLane, txOf, - type Transaction + type Transaction, + installLaneHooks } from "./scheduler.js"; import type { Computed, Signal } from "./types.js"; @@ -786,20 +787,16 @@ function answered(el: Computed): number { return q; } -GlobalQueue._laneRead = laneRead; -GlobalQueue._laneStage = laneStage; -GlobalQueue._laneOutcome = laneOutcome; -GlobalQueue._laneWrite = laneWrite; /** The lanes alive before this seam's guesses opened theirs (a prefix of * `lanes`; nothing dissolves one between here and the seam loop): a lane * born at the seam has no work to judge yet (its passes run next round) — * it is neither shown nor blocked until it has. */ let judged = 0; -GlobalQueue._applyGuesses = parent => { +const applyGuessesAt = (parent: Transaction | null): void => { judged = lanes.length; if (pendingGuesses.length !== 0) applyGuesses(parent); }; -GlobalQueue._laneSeams = leaks => { +const laneSeams = (leaks: Computed[] | null): void => { // Passes that read a write the parked frame holds as the screen // (`stagedReaders`: lane work, a verdict reader before the verdict): they // re-derive on the committed world next round (and register as stale @@ -837,8 +834,17 @@ GlobalQueue._laneSeams = leaks => { for (let k = Math.min(judged, lanes.length) - 1; k >= 0; k--) laneSeam(lanes[k], leaked.indexOf(lanes[k]) !== -1); }; -GlobalQueue._laneCorrections = () => lanes.length !== 0 && laneCorrections(); -GlobalQueue._endLanes = endLanes; +installLaneHooks( + laneRead, + laneStage, + laneOutcome, + laneWrite, + applyGuessesAt, + laneSeams, + () => lanes.length !== 0 && laneCorrections(), + endLanes, + lanesBlocked +); if (__OBSERVE__) GlobalQueue._laneGuesses = t => { const out: Signal[] = []; @@ -851,5 +857,4 @@ if (__OBSERVE__) } return out; }; -GlobalQueue._lanesBlocked = lanesBlocked; GlobalQueue._verdictLane = verdictLane; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 3108bf291a..057ad41f08 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -37,6 +37,129 @@ import { deleteFromHeap, enqueueSub, runHeap, type Heap } from "./heap.js"; import { devCheckQuiescent } from "./invariants.js"; import type { Computed, Owner, Signal } from "./types.js"; +// Optional-module hooks: module bindings, not `GlobalQueue` statics, so a +// bundle that never imports the installing module never assigns them — its +// minifier folds every guarded call site (and the branch behind it) away. +// Each module installs its own set at evaluation (pay-for-use). +// +// Lanes (lanes.ts; installed when `createOptimistic` — or verdict.ts — is +// imported; every call site is behind a bit or a `passLane` nothing else sets). +export let laneReadHook: + | ((c: Computed | null, el: Signal | Computed) => unknown) + | null = null; +export let laneStageHook: + | (( + el: Computed, + l: Transaction, + create: boolean, + errored: boolean | Computed + ) => boolean) + | null = null; +export let laneOutcomeHook: + | ((el: Computed, value: unknown, errored: boolean) => boolean) + | null = null; +export let laneWriteHook: ((el: Signal | Computed, v: T) => T) | null = null; +export let applyGuessesHook: ((parent: Transaction | null) => void) | null = null; +export let laneSeamsHook: ((leaks: Computed[] | null) => void) | null = null; +export let laneCorrectionsHook: (() => boolean) | null = null; +export let endLanesHook: ((u: Transaction) => void) | null = null; +export let lanesBlockedHook: ((t: Transaction) => boolean) | null = null; +export function installLaneHooks( + read: NonNullable, + stage: NonNullable, + outcome: NonNullable, + write: NonNullable, + applyGuesses: NonNullable, + seams: NonNullable, + corrections: NonNullable, + end: NonNullable, + blocked: NonNullable +): void { + laneReadHook = read; + laneStageHook = stage; + laneOutcomeHook = outcome; + laneWriteHook = write; + applyGuessesHook = applyGuesses; + laneSeamsHook = seams; + laneCorrectionsHook = corrections; + endLanesHook = end; + lanesBlockedHook = blocked; +} +// Boundaries (boundaries.ts): the display consumers between an observer +// and the root. `catch` — status from a frame reader, nearest boundary +// first (true: caught, the root never hears of it; a clear — flags 0 — +// settles the reader there); `fresh` — a pass that read a hold (true: a +// first pass a loading boundary that has not shown content caught, +// `joinPass`; `held`: the flush's hold owns it, nothing catches it); +// `hidden` — a frame reader behind a fallback (the boundary showing it) +// is not on screen and holds nothing; `boundarySeam` — the seam's sweep (readers +// gone or settled without a pass reveal; an `on` re-arm resolves); +// `heldRun` — a queued run under a fallback-showing boundary waits for +// the reveal (true: held; the boundary re-queues it by type). The +// synchronous first render on creation builds the subtree, attached or +// not; its updates and the user effects wait. +export let catchHook: ((node: Computed, flags: number, error: unknown) => unknown) | null = + null; +export let freshHook: ((node: Computed, held?: unknown) => unknown) | null = null; +export let hiddenHook: ((r: Computed) => unknown) | null = null; +export let boundarySeamHook: (() => void) | null = null; +export let heldRunHook: ((node: Computed) => boolean) | null = null; +export function installBoundaryHooks( + caught: NonNullable, + fresh: NonNullable, + hidden: NonNullable, + seam: NonNullable, + heldRun: NonNullable +): void { + catchHook = caught; + freshHook = fresh; + hiddenHook = hidden; + boundarySeamHook = seam; + heldRunHook = heldRun; +} +/** Store (store/store.ts): `park` — the flush parked into `t`: the store + * materializes and holds the container node of every staging that had none + * (a staging needs the node as its home only when something can read the + * frame through it; a hold can); `commit` — fold the pending backings whose + * container nodes this flush committed (the owned-raw model's one mutation + * point). */ +export let storeParkHook: ((t: Transaction) => void) | null = null; +export let storeCommitHook: (() => void) | null = null; +export function installStoreHooks( + park: NonNullable, + commit: NonNullable +): void { + storeParkHook = park; + storeCommitHook = commit; +} +/** `affects()` marks (affects.ts): the releases at a landing and at the seam + * (ambient marks). */ +export let releaseMarksHook: ((nodes: Signal[]) => void) | null = null; +export let releaseAmbientMarksHook: ((parked: Transaction | null) => void) | null = null; +export function installMarkHooks( + release: NonNullable, + releaseAmbient: NonNullable +): void { + releaseMarksHook = release; + releaseAmbientMarksHook = releaseAmbient; +} +/** Verdicts (verdict.ts). */ +export let observeFlightHook: ((c: Computed, el: Computed) => boolean) | null = null; +export function installVerdictHooks(observeFlight: NonNullable): void { + observeFlightHook = observeFlight; +} +/** External-source bridge (wired by enableExternalSource(); undefined while + * no config is active — including after _resetExternalSourceConfig()). */ +export let wireExternalSourceHook: ((self: Computed) => void) | undefined = undefined; +export let externalUntrackHook: ((fn: () => T) => T) | undefined = undefined; +export function setExternalHooks( + wire: typeof wireExternalSourceHook, + untrack: typeof externalUntrackHook +): void { + wireExternalSourceHook = wire; + externalUntrackHook = untrack; +} + // CARVE 3: transactions (Transition objects, the ambient batch, holds/parks, // stashed effect queues, async reporters, actions, zombies, entanglement, // origin/provenance) were removed on the measurement branch. Same-flush @@ -258,7 +381,7 @@ export function blocked(t: Transaction): boolean { judge ??= t; // An action still running in it holds it open (action.ts). Its lanes // (lanes.ts): a lane under `t` blocks it while blocked itself. - const r = t._open !== 0 || blockedBy(t._nodes, t) || !!GlobalQueue._lanesBlocked?.(t); + const r = t._open !== 0 || blockedBy(t._nodes, t) || !!lanesBlockedHook?.(t); judge = prev; return r; } @@ -353,7 +476,7 @@ function blockedBy(nodes: Signal[], owner: Transaction, own = false): boole * everything under it, so a reader there is not on screen and holds * nothing — boundaries.ts). */ function onScreen(r: Computed, t: Transaction): boolean { - return !(r._flags & REACTIVE_ZOMBIE && removalStagedBy(r, t)) && !GlobalQueue._hidden?.(r); + return !(r._flags & REACTIVE_ZOMBIE && removalStagedBy(r, t)) && !hiddenHook?.(r); } /** A15 (#3463): a reader whose removal is staged — a zombie, parked by a * pass awaiting its commit — is still on screen and live for every hold, @@ -626,29 +749,6 @@ export class GlobalQueue implements IQueue { declare static _update: (el: Computed) => void; declare static _dispose: (el: Computed, self: boolean, zombie?: boolean) => void; declare static _runEffect: (el: Computed) => void; - // External-source bridge (wired by enableExternalSource(); null while no - // config is active — including after _resetExternalSourceConfig()). - declare static _wireExternalSource: ((self: Computed) => void) | undefined; - declare static _externalUntrack: ((fn: () => T) => T) | undefined; - // Lanes (lanes.ts; installed when `createOptimistic` — or verdict.ts — is - // imported; null otherwise, and every call site is behind a bit or a - // `passLane` that nothing else sets). - declare static _laneRead: - | ((c: Computed | null, el: Signal | Computed) => unknown) - | undefined; - declare static _laneStage: - | (( - el: Computed, - l: Transaction, - create: boolean, - errored: boolean | Computed - ) => boolean) - | undefined; - declare static _laneOutcome: - | ((el: Computed, value: unknown, errored: boolean) => boolean) - | undefined; - declare static _laneWrite: ((el: Signal | Computed, v: T) => T) | undefined; - declare static _applyGuesses: ((parent: Transaction | null) => void) | undefined; /** A slot node's truth is in flight (its family's derive — store/store.ts). */ declare static _slotFlight: ((n: Signal) => boolean) | undefined; /** The value a slot node's guess covered: its committed value — a chained @@ -673,51 +773,15 @@ export class GlobalQueue implements IQueue { * `laneWrite`): the store re-bases an arrangement guess over it * (store/optimistic.ts). */ declare static _laneRebase: ((el: Signal, truth: unknown) => void) | undefined; - declare static _laneSeams: ((leaks: Computed[] | null) => void) | undefined; - declare static _laneCorrections: (() => boolean) | undefined; - declare static _endLanes: ((u: Transaction) => void) | undefined; - declare static _lanesBlocked: ((t: Transaction) => boolean) | undefined; + // Lanes (lanes.ts). declare static _verdictLane: ((t: Transaction) => Transaction) | undefined; - // Verdicts (verdict.ts). - declare static _observeFlight: ((c: Computed, el: Computed) => boolean) | undefined; // Observe tier (attribution.ts): the guesses of `t`'s lanes (lanes.ts). declare static _laneGuesses: ((t: Transaction) => Signal[]) | undefined; - /** Store (store/store.ts): fold the pending backings whose container - * nodes this flush committed — the owned-raw model's one mutation point. */ - declare static _storeCommit: (() => void) | undefined; - /** The flush parked into `t`: the store materializes and holds the - * container node of every staging that had none (store/store.ts — a - * staging needs the node as its home only when something can read the - * frame through it; a hold can). */ - declare static _storePark: ((t: Transaction) => void) | undefined; - // `affects()` marks (affects.ts): the probe's coverage test, the releases - // at a landing and at the seam (ambient marks). /** `createDeferred` (deferred.ts): a deferred node with a flight up that * `c` derives from, if any — a truth reader waits on it. */ declare static _lagging: ((c: Computed) => Computed | undefined) | undefined; + // `affects()` marks (affects.ts): the probe's coverage test. declare static _marked: ((el: Signal | Computed) => boolean) | undefined; - declare static _releaseMarks: ((nodes: Signal[]) => void) | undefined; - declare static _releaseAmbientMarks: ((parked: Transaction | null) => void) | undefined; - // Boundaries (boundaries.ts): the display consumers between an observer - // and the root. `_catch` — status from a frame reader, nearest boundary - // first (true: caught, the root never hears of it; a clear — flags 0 — - // settles the reader there); `_fresh` — a pass that read a hold (true: a - // first pass a loading boundary that has not shown content caught, - // `joinPass`; `held`: the flush's hold owns it, nothing catches it); - // `_hidden` — a frame reader behind a fallback (the boundary showing it) - // is not on screen and holds nothing; `_boundarySeam` — the seam's sweep (readers - // gone or settled without a pass reveal; an `on` re-arm resolves). - declare static _catch: - | ((node: Computed, flags: number, error: unknown) => unknown) - | undefined; - declare static _fresh: ((node: Computed, held?: unknown) => unknown) | undefined; - declare static _hidden: ((r: Computed) => unknown) | undefined; - declare static _boundarySeam: (() => void) | undefined; - // `_heldRun` — a queued run under a fallback-showing boundary waits for - // the reveal (true: held; the boundary re-queues it by type). The - // synchronous first render on creation builds the subtree, attached or - // not; its updates and the user effects wait. - declare static _heldRun: ((node: Computed) => boolean) | undefined; flush() { if (this._running) return; @@ -769,7 +833,7 @@ export class GlobalQueue implements IQueue { // Lanes: an ended action body judges its guesses now that their passes // have run (lanes.ts); a correction's re-derivations run in one more // pure round — the parent's flights before the parent is judged below. - if (GlobalQueue._laneCorrections?.()) runHeap(dirtyQueue, GlobalQueue._update); + if (laneCorrectionsHook?.()) runHeap(dirtyQueue, GlobalQueue._update); const joined = flushTransaction; flushTransaction = null; // The frame's verdict is in: the guesses written since the last seam @@ -777,7 +841,7 @@ export class GlobalQueue implements IQueue { // parents a nested lane to it), or are dropped — the frame commits, and // there is nothing to be optimistic over. Their passes run next round; // a parent landing at this very seam ends the lane before they do. - GlobalQueue._applyGuesses?.(joined); + applyGuessesHook?.(joined); // A lane's own flush (its flight landing, its reveal) parks nothing: the // lane's work reached it per pass; the frame's plain writes commit. // (Resolved: a guess over another transaction's guess merged them.) @@ -834,7 +898,7 @@ export class GlobalQueue implements IQueue { if (n._config & CONFIG_STAGED) holdFrame(n as unknown as Owner, t); } pendingNodes.length = 0; - GlobalQueue._storePark?.(t); + storeParkHook?.(t); // (This flush's runs are stashed with `t` below — after the landings, // so a `t` that lands at this very seam runs them first, ahead of @@ -845,13 +909,17 @@ export class GlobalQueue implements IQueue { // through like the same read outside): it re-runs now, as the // holder's verdict lane's work — before the lane seams, which reveal // it (nothing commits this seam that could dispose it). - const zombies = deferredZombies.splice(0).sort((a, b) => a._height - b._height); - for (let i = 0; i < zombies.length; i++) - if ( - (zombies[i]._flags & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE)) === REACTIVE_ZOMBIE && - zombies[i]._config & CONFIG_VERDICT - ) - recompute(zombies[i]); + // (Verdict readers exist only with the verdict engine installed.) + const zombies = deferredZombies.splice(0); + if (observeFlightHook) { + zombies.sort((a, b) => a._height - b._height); + for (let i = 0; i < zombies.length; i++) + if ( + (zombies[i]._flags & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE)) === REACTIVE_ZOMBIE && + zombies[i]._config & CONFIG_VERDICT + ) + recompute(zombies[i]); + } // The unchanged passes' tails stay linked (A30, #3469): their inputs // are held, and the committed frame still derives from them — no trim. } else { @@ -882,14 +950,16 @@ export class GlobalQueue implements IQueue { // The passes that read this frame's stagings as the screen read held // writes if it parked: the lane seam re-derives them on the committed // world (a lane's runs wait this round). - GlobalQueue._laneSeams?.(t !== null ? stagedReaders : null); - if (stagedReaders.length) stagedReaders.length = 0; + if (laneSeamsHook) { + laneSeamsHook(t !== null ? stagedReaders : null); + if (stagedReaders.length) stagedReaders.length = 0; + } this._queues = [[], []]; for (let k = transactions.length - 1; k >= 0; k--) { const u = transactions[k]; if (blocked(u)) continue; transactions.splice(k, 1); - GlobalQueue._endLanes?.(u); + endLanesHook?.(u); land(u); // A landing's commits can dispose a zombie a transaction judged above // was blocked on (#3463: live "until the commit that disposes it") — @@ -898,13 +968,13 @@ export class GlobalQueue implements IQueue { } // The store folds the pending backings whose container nodes committed // — this flush's, or a landing's (store/store.ts installs it). - GlobalQueue._storeCommit?.(); + storeCommitHook?.(); // Marks declared outside a transaction: the flush that carried them // parked (something async below) — the window is the frame's, the marks // are its transaction's; else they release at the seam, verdict-only, // nothing to show (affects.ts). const live = t && liveTx(t); - GlobalQueue._releaseAmbientMarks?.(live); + releaseAmbientMarksHook?.(live); // The effect phase: lane work first (displayed ahead of the frame), then // this flush's runs, then the runs the landings held from earlier // flushes — as one pass would have queued them (#3540: a shell @@ -942,7 +1012,7 @@ export class GlobalQueue implements IQueue { } // Boundaries: readers that settled, landed or died this flush are // dropped; a fallback with none left reveals next round. - GlobalQueue._boundarySeam?.(); + boundarySeamHook?.(); } run(type: number) { const effects = this._queues[type - 1]; @@ -974,7 +1044,7 @@ export class GlobalQueue implements IQueue { * first: a fallback catches what is not ready under it, and the root * never hears of it. */ notify(node: Computed, mask: number, flags: number, error?: any): boolean { - if (GlobalQueue._catch?.(node, flags & mask, error)) return true; + if (catchHook?.(node, flags & mask, error)) return true; if (mask & STATUS_PENDING) { if (flags & STATUS_PENDING) { if (__DEV__ && _enforceLoadingBoundary) _hitUnhandledAsync = true; @@ -1036,7 +1106,7 @@ function land(u: Transaction): void { // (`_nodes` still lists them). if (__OBSERVE__ && attrHooks !== null) attrHooks.transitionSettled(u); reruns(u); - if (u._marks !== null) GlobalQueue._releaseMarks!(u._marks); + if (u._marks !== null) releaseMarksHook!(u._marks); // Old children die in the commits (cleanups first), then the stashed // effects run ahead of this flush's own. A node a lane took over since // (a guess written over the staged truth) is the lane's to land. diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 0eba01927d..587e22b079 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -59,7 +59,8 @@ import { stagedReaders, staleReader, txOf, - type Transaction + type Transaction, + installVerdictHooks } from "./scheduler.js"; import type { Computed, Root, Signal } from "./types.js"; @@ -483,7 +484,7 @@ function verdictValue(el: Signal | Computed, c: Computed | null): // Installed at module evaluation — present exactly when something imports // `latest` or `isPending`. (`read` dispatches to `verdictValue` through // `verdict`, set while a window is open.) -GlobalQueue._observeFlight = observeFlight; +installVerdictHooks(observeFlight); /** A store read inside an untracked `isPending` probe found a live mark on * a record with no node to carry it (store/affects.ts): not final. */ GlobalQueue._witnessMark = () => { diff --git a/packages/signals/src/store/affects.ts b/packages/signals/src/store/affects.ts index 6260861845..3c701470df 100644 --- a/packages/signals/src/store/affects.ts +++ b/packages/signals/src/store/affects.ts @@ -23,7 +23,7 @@ * (`_storeNode`, `_storeWrappable`), and node birth and the untracked traps * reach back through `affectsHooks` (hooks.ts). */ -import { GlobalQueue } from "../core/scheduler.js"; +import { GlobalQueue, releaseMarksHook } from "../core/scheduler.js"; import type { Computed, Signal } from "../core/types.js"; import { installAffectsHooks, optHooks, type AffectsHooks } from "./hooks.js"; import { $OWNER, lookupTarget, type StoreFamily, type StoreTarget } from "./target.js"; @@ -157,5 +157,5 @@ export function releaseMarkScope(carrier: Marked): void { const entry = scopes.get(carrier); if (entry === undefined) return; scopes.delete(carrier); - GlobalQueue._releaseMarks!(entry._inherited); + releaseMarksHook!(entry._inherited); } diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 175d1686c1..0125226243 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -88,7 +88,8 @@ import { queuePendingNode, schedule, txOf, - type Transaction + type Transaction, + installStoreHooks } from "../core/scheduler.js"; import type { Computed, Owner, Signal } from "../core/types.js"; import { @@ -461,7 +462,7 @@ export function getContainerNode(target: StoreTarget): Signal { * staging: nothing allocates, nothing is swept at the commit (dbmon's tick * re-created, queued, swept and dropped ~7000 container nodes). If the * flush parks, the seam materializes and holds the node then - * (`_storePark`) — the one case the staging home exists for. */ + * (`storeParkHook`) — the one case the staging home exists for. */ function stageOn(target: StoreTarget, pb: Record): void { if ( target.k === null && @@ -486,7 +487,7 @@ function deriveHolds(target: StoreTarget): boolean { return fw != null && !userWrite && (fw._config & CONFIG_HELD) !== 0; } -GlobalQueue._storePark = t => { +const storePark = (t: Transaction): void => { for (let i = 0; i < foldList.length; i++) { const target = foldList[i]; if (target.k === null && target.pb !== null) holdNode(getContainerNode(target), t); @@ -923,10 +924,10 @@ GlobalQueue._slotCovered = (n: Signal): unknown => { } return n._value; }; -GlobalQueue._storeCommit = () => { +installStoreHooks(storePark, () => { drainFolds(); optHooks?.sweep(); -}; +}); /** The store half of `affects()` (store/affects.ts) makes its marks' nodes * and walks records through these: it never imports the engine. */ GlobalQueue._storeNode = getNode; diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts index b1ee03a859..8be9b8e025 100644 --- a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -4,7 +4,7 @@ * boundary-scope ruling). * * A first pass that reads a hold asks the boundaries up its chain - * (`GlobalQueue._fresh`, the real `catchStatus` walk); when a loading + * (`freshHook`, the real `catchStatus` walk); when a loading * boundary that has not shown content catches it, the pass is the * boundary's, not the tick's: the boundary shows its fallback, the mount * publishes, and the content appears at the hold's commit. Outside such a diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 21b48b2c83..0a47bd31f6 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -1429,6 +1429,9 @@ function checkUnscopedHole(snapshot, accessor, parent) { // the guards away entirely in CSR bundles; esbuild keeps the ~30-byte residue // but drops these bodies once nothing else references them. let hydrationRt = null; +// The `