From 8c1ec003b337507bb1036102d86d7095cf15bfce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 11:47:59 +0000 Subject: [PATCH 1/8] spike: dependency edges on top of deferred hydration Server: a plain signal created under an id-bearing owner is named `~` (n per owner, reset wherever the child counter resets: retries, row owners, hole scopes, the boundary resolve scope). Reads report the name to the current sink: a slot fill's, a memo's (re-reported when the memo is read, so an edge through a memo lands on its sources), or the request's wide sink for a projection's compute. The document sink aggregates each frame's fills into `sc:deps::` records, `sid -> "card#0-399 cart#0"`, plus `*` (wide signals) and `!` (fills that left async holes). Client: with the `sc-edges=1` cookie the frames client names plain signals the same way and its pre-write hook mounts only the unmounted occurrences that read the written signal (from the frame's own walk; a nested one through the enclosing chain). Anything the records cannot vouch for mounts everything, as before: no name (stores, derived or optimistic signals), a wide signal, a write while the document streams, or a deferred frame under a root without records. `sc-defer=0` turns deferral off. A/B switches and `__sc*` debug globals are spike-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/signals/src/core/core.ts | 35 ++++- packages/signals/src/core/index.ts | 2 + packages/signals/src/core/owner.ts | 6 +- packages/signals/src/index.ts | 2 +- packages/signals/src/signals.ts | 2 + packages/solid/src/index.ts | 6 + packages/solid/src/internal.ts | 11 +- packages/solid/src/server/hydration.ts | 5 + packages/solid/src/server/index.ts | 5 +- packages/solid/src/server/signals.ts | 117 +++++++++++++++-- packages/web/frames/src/client.ts | 164 +++++++++++++++++++++++- packages/web/frames/src/frame-client.ts | 33 ++++- packages/web/frames/src/frame-sink.ts | 83 +++++++++++- 13 files changed, 449 insertions(+), 22 deletions(-) diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 7f5219003..26b678499 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1139,6 +1139,7 @@ function parkChildren(el: Computed): void { el._disposal = null; el._firstChild = null; el._childCount = 0; + if (sigCounts !== null) sigCounts.delete(el); } /** @@ -2035,14 +2036,42 @@ export function captureWriteSnapshot(el: Signal | Computed, current: T) * unmounted component would have observed, so the client mounts what is * left before the write applies, and clears the hook. `null` otherwise. */ -export let writeHook: (() => void) | null = null; +export let writeHook: ((sid?: string) => void) | null = null; /** @internal */ -export function setWriteHook(hook: (() => void) | null): void { +export function setWriteHook(hook: ((sid?: string) => void) | null): void { writeHook = hook; } +// SPIKE (deferred hydration, dependency edges): plain-signal identities. +// While on (`setSignalIdentities`, the server-components client turns it on +// before hydrating), a plain signal created under an id-bearing owner is +// named `~`, n counting the plain signals created under that +// owner (transparent owners defer to the nearest id-bearing one, as child +// ids do), reset wherever the owner's child counter resets. The server names +// the same signal the same way, so an edge it shipped (a slot fill read it) +// matches the client node; the write hook receives the name. +let sigCounts: WeakMap | null = null; +/** @internal */ +export function setSignalIdentities(on: boolean): void { + sigCounts = on ? sigCounts || new WeakMap() : null; +} +/** @internal Reset an owner's plain-signal counter (with its child counter). */ +export function resetSignalCount(owner: Owner): void { + if (sigCounts !== null) sigCounts.delete(owner); +} +/** @internal */ +export function identifySignal(node: Signal, owner: Owner | null): void { + if (sigCounts === null || !owner) return; + let o: Owner = owner; + while (o._config & CONFIG_TRANSPARENT && o._parent) o = o._parent; + if (o.id == null) return; + const n = sigCounts.get(o) || 0; + sigCounts.set(o, n + 1); + (node as any)._sid = o.id + "~" + n; +} + export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T)): T { - if (writeHook !== null) writeHook(); + if (writeHook !== null) writeHook((el as any)._sid); if ( __DEV__ && !(el._config & CONFIG_OWNED_WRITE) && diff --git a/packages/signals/src/core/index.ts b/packages/signals/src/core/index.ts index 9c55d1727..b4fb0a289 100644 --- a/packages/signals/src/core/index.ts +++ b/packages/signals/src/core/index.ts @@ -10,6 +10,8 @@ export { read, setSignal, setWriteHook, + setSignalIdentities, + identifySignal, setMemo, markRefresh, staleValues, diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts index 487354ce7..74eba3a25 100644 --- a/packages/signals/src/core/owner.ts +++ b/packages/signals/src/core/owner.ts @@ -15,6 +15,7 @@ import { enterDisposal, exitDisposal, PRIMITIVE_IN_FORBIDDEN_SCOPE_MESSAGE, + resetSignalCount, runWithOwner, tracking } from "./core.js"; @@ -126,7 +127,10 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo // would still be holding. child = child._nextSibling; } - if (!zombie) node._childCount = 0; + if (!zombie) { + node._childCount = 0; + resetSignalCount(node); + } // O(1) splice out of parent's chain on individual dispose. Skipped during // batch dispose (parent already disposed) and for a zombie (it sits on the // parent's parked frame, drained whole by the commit). We leave diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index a2aec9778..50d115043 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -39,7 +39,7 @@ export { */ export { setConsoleFooter } from "./core/index.js"; /** @internal The pre-write hook (`setWriteHook` in core/core.ts); the server-components client's deferred hydration. */ -export { setWriteHook } from "./core/index.js"; +export { setWriteHook, setSignalIdentities } from "./core/index.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js"; /** * Observe tier (diagnostics channel, attribution hook slot + interaction diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index c91210188..01ad3feac 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -20,6 +20,7 @@ import { runWithOwner, setMemo, setSignal, + identifySignal, signal, trackedEffect, untrack, @@ -416,6 +417,7 @@ export function createSignal( } const node = signal(first as any, second as SignalOptions); if (__OBSERVE__) registerGraph(node, getOwner()); + identifySignal(node, getOwner()); return [accessor(node), setSignal.bind(null, node as any) as Setter]; } diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index d4a3d384b..a35be965b 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -184,6 +184,12 @@ export function creationStamp(): number { } /** @internal A callback run before every signal or store write lands (deferred hydration). */ export { setWriteHook } from "@solidjs/signals"; +/** @internal SPIKE (dependency edges): name plain signals so a write reports which one. */ +export { setSignalIdentities } from "@solidjs/signals"; +/** @internal SPIKE (dependency edges): server-only read tracking; the client runs `fn`. */ +export function trackFillReads(fn: () => T): { result: T; reads: Set | null } { + return { result: fn(), reads: null }; +} /** @internal */ export function inServerComponentScope(): boolean { return false; diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index 8c1940e1e..6508ed04a 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -129,7 +129,16 @@ export const creationStamp: () => number = core.creationStamp; * write lands — the server-components client's deferred hydration mounts * what it left unmounted. Server: no-op. */ -export const setWriteHook: (hook: (() => void) | null) => void = core.setWriteHook; +export const setWriteHook: (hook: ((sid?: string) => void) | null) => void = core.setWriteHook; +/** SPIKE (dependency edges). Client: name plain signals (`~`) from now on. Server: no-op. */ +export const setSignalIdentities: (on: boolean) => void = core.setSignalIdentities; +/** + * SPIKE (dependency edges). Server: run a slot fill, returning the plain-signal + * identities it read (through memos too), and those any projection read + * (`wide`: their readers are unknown). Client: runs it. + */ +export const trackFillReads: (fn: () => T) => { result: T; reads: Set | null } = + core.trackFillReads; /** Server: the live projection trace behind a value, if any. Client: `undefined`. */ export const getProjectionTrace: ( diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 4b62b2738..1465d372c 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -516,16 +516,21 @@ function ssrLoadingBoundary( // hydrated dead (#3414). Retry passes resume the surviving holes in the // same scope, the counter continuing where the last pass left it. let resolveCount = 0; + let resolveSig = 0; function resolveIn(run: () => T): T { const prevCount = (o as any)._childCount; + const prevSig = (o as any)._sigCount; (o as any).id = flattenId; (o as any)._childCount = resolveCount; + (o as any)._sigCount = resolveSig; try { return run(); } finally { resolveCount = (o as any)._childCount; + resolveSig = (o as any)._sigCount; (o as any).id = contentId; (o as any)._childCount = prevCount; + (o as any)._sigCount = prevSig; } } diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 0fe82a2e3..587dab4bd 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -150,6 +150,7 @@ export { createErrorBoundary, createRevealOrder } from "./signals.js"; /** @internal */ export { creationStamp, + trackFillReads, runInServerComponentScope, inServerComponentScope, inLiveServerComponentScope, @@ -160,7 +161,9 @@ export { } from "./signals.js"; export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; /** @internal Client-only: the server has no writes to observe. */ -export function setWriteHook(_hook: (() => void) | null): void {} +export function setWriteHook(_hook: ((sid?: string) => void) | null): void {} +/** @internal SPIKE (dependency edges): client-only. */ +export function setSignalIdentities(_on: boolean): void {} /** @internal */ export { ssrHandleError, ssrScope } from "./hydration.js"; // After the runtime modules above, so this import adds no edge to the module diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 52f7ca0ba..9664ed3f5 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -147,6 +147,8 @@ interface SSROwner { _parent: SSROwner | null; _context: Record; _childCount: number; + /** SPIKE (dependency edges): plain signals created under this owner, reset with `_childCount`. */ + _sigCount: number; _firstChild: SSROwner | null; _nextSibling: SSROwner | null; _disposed: boolean; @@ -337,6 +339,7 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS owner._parent = parent; owner._context = ctx; owner._childCount = 0; + owner._sigCount = 0; owner._firstChild = null; owner._nextSibling = null; owner._disposed = false; @@ -358,6 +361,7 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS _parent: parent, _context: ctx, _childCount: 0, + _sigCount: 0, _firstChild: null, _nextSibling: null, _disposed: false, @@ -372,6 +376,7 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS _parent: parent, _context: ctx, _childCount: 0, + _sigCount: 0, _firstChild: null, _nextSibling: null, _disposed: false, @@ -539,7 +544,7 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { // retry reset (`self=false`) must still rewind the counter — otherwise // every boundary discovery pass drifts the ids of the eventual // successful run past the client's (#2900). - node._childCount = 0; + node._childCount = node._sigCount = 0; node._scopeSlot = -1; } return; @@ -552,7 +557,7 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { child = next; } node._firstChild = null; - node._childCount = 0; + node._childCount = node._sigCount = 0; // Defensive: a kept-alive owner (`self=false`) re-runs from its own, // settled id. The slot is swap-owned (`ssrScope` restores it in `finally`), // so this is never observed set here — pinned by `ownerId`'s dev check. @@ -594,7 +599,7 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { export function resetOwnerForRerun(owner: Owner): void { const node = owner as unknown as SSROwner; if (node._firstChild || node._disposal) disposeOwner(owner, false); - node._childCount = 0; + node._childCount = node._sigCount = 0; node._scopeSlot = -1; } @@ -656,18 +661,22 @@ export function ssrScope(fn: () => T): () => unknown { // inner effect. let resume: (() => unknown) | undefined; let resumeCount = 0; + let resumeSig = 0; return () => { const prevId = parent.id; const prevSlot = parent._scopeSlot; const prevCount = parent._childCount; + const prevSig = parent._sigCount; parent.id = prefix; parent._scopeSlot = slot; parent._childCount = resume ? resumeCount : 0; + parent._sigCount = resume ? resumeSig : 0; // The unwrap step in flight, so the one catch below can record the // resume point: a single handler frame on the NotReady unwind (see // `runWithOwner`) instead of an inner catch and an outer finally. let next: (() => unknown) | undefined; let count = 0; + let sigCount = 0; let v: unknown; try { v = resume || fn(); @@ -679,21 +688,25 @@ export function ssrScope(fn: () => T): () => unknown { while (typeof v === "function") { next = v as () => unknown; count = parent._childCount; + sigCount = parent._sigCount; v = next(); } } catch (err) { parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; + parent._sigCount = prevSig; if (next !== undefined && err instanceof NotReadyError) { resume = next; resumeCount = count; + resumeSig = sigCount; } throw err; } parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; + parent._sigCount = prevSig; return v; }; } @@ -1224,6 +1237,58 @@ function warnServerWrite(category: "signal" | "store" | "optimistic"): void { // projections, and of any memo whose last compute read one of those. let asyncReads = 0; +// --------------------------------------------------------------------------- +// SPIKE (deferred hydration, dependency edges): plain-signal identities and +// read tracking. A plain signal created under an id-bearing owner is named +// `~` (n counts plain signals under that owner, reset with its +// child counter), as the client's signals core names it. A read reports the +// name to the current sink: a slot fill's (`trackFillReads`), a memo's — a +// memo read re-reports what its compute read, so an edge through a memo +// lands on its sources — or the request's WIDE sink, for reads whose +// readers are not tracked (a projection's compute): a write to a wide +// signal mounts everything left. +type ReadSink = { s: Set | null; wide?: true }; +let readSink: ReadSink | null = null; +function reportRead(sid: string): void { + const sink = readSink!; + (sink.s ||= new Set()).add(sid); +} +function reportReads(reads: Set | null): void { + if (reads === null || readSink === null) return; + const s = (readSink.s ||= new Set()); + for (const sid of reads) s.add(sid); +} +function signalIdentity(owner: SSROwner | null): string | undefined { + if (!owner) return undefined; + let o: SSROwner = owner; + while (o._transparent && o._parent) o = o._parent; + if (o.id == null) return undefined; + const id = materializeId(o); + return id + "~" + o._sigCount++; +} +/** @internal SPIKE: run `fn`, returning its result and the plain-signal identities it read. */ +export function trackFillReads(fn: () => T): { result: T; reads: Set | null } { + const prev = readSink; + const sink: ReadSink = { s: null }; + readSink = sink; + try { + return { result: fn(), reads: sink.s }; + } finally { + readSink = prev; + } +} +/** Run `fn` with its reads reported to the request's wide sink (`context.edgeWide`). */ +function wideReads(ctx: any, fn: () => T): T { + if (!ctx) return fn(); + const prev = readSink; + readSink = ctx.edgeWide ||= { s: null, wide: true }; + try { + return fn(); + } finally { + readSink = prev; + } +} + export function createSignal(): Signal; export function createSignal(value: Exclude, options?: SignalOptions): Signal; // Commit #0 (loadingValue) removes the uninitialized window: never undefined @@ -1260,9 +1325,16 @@ export function createSignal( }) as Setter ]; } - // Plain value form — no ID allocation (IDs are only for owners/computations) + // Plain value form — no ID allocation (IDs are only for owners/computations). + // SPIKE (dependency edges): an identity, reported on read (see `readSink`). + const sid = signalIdentity(currentOwner); return [ - () => first as T, + sid !== undefined + ? () => { + if (readSink !== null) reportRead(sid); + return first as T; + } + : () => first as T, v => { warnServerWrite("signal"); return ((first as any) = typeof v === "function" ? (v as (prev: T) => T)(first as T) : v); @@ -1366,14 +1438,20 @@ export function createMemo( // after another request may have set its own, and everything the pass // creates — a sync memo it pulls, a slot's content — would take that // render's ids (#3972). + // SPIKE (dependency edges): what the last compute read, re-reported on read. + let memoReads: Set | null = null; const run = () => { resetOwnerForRerun(owner); const prev = sharedConfig.context; sharedConfig.context = ctx; const reads = asyncReads; + const prevSink = readSink; + const sink: ReadSink = (readSink = { s: null }); try { return runWithOwner(owner, () => runWithObserver(comp, () => comp.compute(comp.value))); } finally { + readSink = prevSink; + memoReads = sink.s; sharedConfig.context = prev; if (asyncReads !== reads) comp.moving = true; } @@ -1463,6 +1541,7 @@ export function createMemo( update(); } if (comp.moving) asyncReads++; + if (memoReads !== null) reportReads(memoReads); if (comp.errored) { // A client hole read outside a Loading discovery pass must fail loudly // rather than wedge the stream (see clientHoleRead). Identity check on @@ -1525,6 +1604,8 @@ function createSyncMemo( let epoch: number | undefined; // See ServerComputation.moving. let moving = false; + // SPIKE (dependency edges): what the last compute read, re-reported on read. + let memoReads: Set | null = null; function pull(): T | undefined { // Inlined `runWithOwner` — avoids the per-pull `() => compute(value)` @@ -1541,6 +1622,8 @@ function createSyncMemo( // (#2801, generalized to all retry paths in #2900). resetOwnerForRerun(owner); const reads = asyncReads; + const prevSink = readSink; + const sink: ReadSink = (readSink = { s: null }); // One handler frame (see `runWithOwner`): the normal path restores // inline, the catch restores and re-raises. let result: T; @@ -1548,6 +1631,8 @@ function createSyncMemo( result = compute(value) as T; } catch (err) { currentOwner = prev; + readSink = prevSink; + memoReads = sink.s; if (asyncReads !== reads) moving = true; if (err instanceof NotReadyError) { // Don't latch a value — the engine re-pulls — but keep the signal @@ -1563,6 +1648,8 @@ function createSyncMemo( throw err; } currentOwner = prev; + readSink = prevSink; + memoReads = sink.s; if (asyncReads !== reads) moving = true; value = result; error = undefined; @@ -1587,6 +1674,7 @@ function createSyncMemo( return (() => { if (moving) asyncReads++; if (cached && (!ctx?.commitEpoch || ctx.commitEpoch() === epoch)) { + if (memoReads !== null) reportReads(memoReads); if (errored) throw error; return value; } @@ -1598,7 +1686,9 @@ function createSyncMemo( // Stale epoch (a commit happened since this computed — frame renders // only) or never computed: (re)pull. Purity is the memo contract, so an // epoch recompute is the client's source-driven re-run, pull-paced. - return pull(); + const v = pull(); + if (memoReads !== null) reportReads(memoReads); + return v; }) as SourceAccessor; } @@ -2481,7 +2571,10 @@ export function createOptimistic( // The updater is not invoked at all — nothing could observe its result. // (Broader direction, recorded: server writes may eventually throw under // a dev-mode server build; that waits on having one.) - const [read] = (createSignal as Function)(first, second) as Signal; + const [read] = + typeof first === "function" + ? ((createSignal as Function)(first, second) as Signal) + : ([() => first as T] as unknown as Signal); return [ read, (() => { @@ -2739,6 +2832,12 @@ export function createProjection( options?: ServerProjectionOptions ): Store { const ctx = sharedConfig.context; + // SPIKE (dependency edges): a projection's readers are not tracked (store + // reads report nothing), so the signals its compute reads are WIDE. + if (ctx) { + const compute = fn; + fn = ((...args: [T]) => wideReads(ctx, () => compute(...args))) as typeof fn; + } const owner = createOwner(); // The projection's own owner, read at creation — never a swapped hole scope. const id = ownerId(owner); @@ -3336,7 +3435,7 @@ export function mapArray( if (origId !== undefined) { rowOwner.id = formatChildId(origId, i); } - rowOwner._childCount = 0; + rowOwner._childCount = rowOwner._sigCount = 0; s.push( options.keyed === false ? indexes @@ -3433,7 +3532,7 @@ export function repeat( if (origId !== undefined) { rowOwner.id = formatChildId(origId, i); } - rowOwner._childCount = 0; + rowOwner._childCount = rowOwner._sigCount = 0; out[i] = mapFn(i + offset); } } finally { diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 94d5b26b6..85e2754f9 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -62,7 +62,12 @@ import { stagedContent, type ServerComponentHandlerOptions } from "./frame-transport.js"; -import { createLoadingBoundary, setWriteHook, sharedConfig } from "solid-js/internal"; +import { + createLoadingBoundary, + setSignalIdentities, + setWriteHook, + sharedConfig +} from "solid-js/internal"; // The container tier (DR-2 case 3): server projections cross the border as // TRACES (snapshot + patch batches) and materialize back into live local @@ -1272,6 +1277,7 @@ function adoptBoundary( // `data-fid` is the site scope: the function id on the first mount, `~n` later. const siteId = el.getAttribute(FRAME_ID_ATTR)!; claimedBoundaries.add(siteId); + edgeRoots.set(siteId, el); // Content is keyed by the CALL's address (the identity split): the frame // binds the address's resident store, while the site scope (`data-fid`) is // the key records and region ids on the page are written under. The address @@ -1593,6 +1599,149 @@ function mountAllDeferred() { deferDone(); } +// SPIKE (deferred hydration, dependency edges). The server ships, per +// document frame, which occurrences read which plain signals they did not +// create (`sc:deps::` batches of `sid -> [occurrence]`; `*` wide +// signals, `!` occurrences with untracked reads). With edges on, a write to +// a named signal mounts only the unmounted occurrences that read it — each +// with the occurrences enclosing it first, as an event would — instead of +// everything left. Anything the records cannot vouch for falls back to +// mounting everything: a write with no name (a store, a derived or +// optimistic signal, an unnamed owner), a wide signal, a write while the +// document is still streaming (records may not have arrived), or a deferred +// frame under a root that shipped no records. +let edges = false; +const edgeRoots = new Map(); +type EdgeIndex = { + readers: Map; + wide: Set; + untracked: [string, string][]; + sites: Set; + starts: Map>; +}; +let edgeIndex: EdgeIndex | undefined; +const DEPS = "sc:deps:"; +function buildEdgeIndex(): EdgeIndex | undefined { + const r = (globalThis as any)._$HY?.r; + if (!r) return undefined; + const idx: EdgeIndex = { + readers: new Map(), + wide: new Set(), + untracked: [], + sites: new Set(), + starts: new Map() + }; + for (const site of edgeRoots.keys()) + for (let n = 0, batch; (batch = r[DEPS + site + ":" + n]); n++) { + if (typeof batch !== "object") return undefined; + idx.sites.add(site); + for (const sid in batch) { + const occs = unpackOccurrences(batch[sid]); + if (sid === "*") occs.forEach(w => idx.wide.add(w)); + else if (sid === "!") occs.forEach(o => idx.untracked.push([site, o])); + else { + let list = idx.readers.get(sid); + if (!list) idx.readers.set(sid, (list = [])); + for (const o of occs) list.push([site, o]); + } + } + } + return idx; +} +/** `card#0-2 cart#0` -> `card#0 card#1 card#2 cart#0` (see the sink's packOccurrences). */ +function unpackOccurrences(packed: string): string[] { + const out: string[] = []; + for (const part of packed.split(" ")) { + const m = /^(.*)#(\d+)-(\d+)$/.exec(part); + if (!m) part && out.push(part); + else for (let i = +m[2]; i <= +m[3]; i++) out.push(m[1] + "#" + i); + } + return out; +} +function startOf(idx: EdgeIndex, site: string, occ: string): Comment | undefined { + let m = idx.starts.get(site); + if (!m) { + idx.starts.set(site, (m = new Map())); + const root = edgeRoots.get(site); + if (root) { + const w = document.createTreeWalker(root, 128 /* SHOW_COMMENT */); + for (let c = w.nextNode() as Comment | null; c; c = w.nextNode() as Comment | null) { + const d = c.data; + if (d.startsWith("slot:") && d.endsWith(":start")) m.set(d.slice(5, -6), c); + } + } + } + return m.get(occ); +} +/** Every deferred frame sits under a root whose records arrived. */ +function edgesCover(idx: EdgeIndex): boolean { + outer: for (const f of deferredFrames()) { + for (const [site, root] of edgeRoots) + if (idx.sites.has(site) && root.contains(f.el)) continue outer; + return false; + } + return true; +} +function mountAll(sid?: string) { + const g = globalThis as any; + const t0 = performance.now(); + const c0 = g.__scDeferMounted || 0; + mountAllDeferred(); + (g.__scEdgeMounts ||= []).push([ + sid ?? null, + "all", + performance.now() - t0, + 0, + g.__scDeferMounted - c0, + c0 + ]); +} +function mountForWrite(sid?: string) { + if (mounting || (sharedConfig as any).hydrating) return; + if (!edges || sid === undefined || document.readyState === "loading") return mountAll(sid); + const t0 = performance.now(); + const idx = (edgeIndex ||= buildEdgeIndex()); + if (!idx || idx.wide.has(sid) || !edgesCover(idx)) return mountAll(sid); + const readers = idx.readers.get(sid); + if (!readers && !idx.untracked.length) return; + const t1 = performance.now(); + let n = 0; + mounting = true; + try { + for (const list of [readers || [], idx.untracked]) + for (const [site, occ] of list) { + // The occurrence's own deferred frame mounts it from its walk; one + // inside an occurrence not mounted yet (a region's fill) is found in + // the document and mounted with its enclosing ones first. + const root = edgeRoots.get(site); + let done = false; + for (const f of deferredFrames()) + if (root && root.contains(f.el) && f.mountOccurrence(occ)) { + done = true; + n++; + break; + } + if (done) continue; + const start = startOf(idx, site, occ); + const target = start && (start.nextSibling || start); + if (!target) continue; + for (let f; (f = liveFrameAt(target)) && f.mountEnclosing(target); ) n++; + } + } finally { + mounting = false; + } + const g = globalThis as any; + (g.__scEdgeMounts ||= []).push([ + sid, + "edges", + performance.now() - t0, + t1 - t0, + n, + g.__scDeferMounted - n + ]); + deferDone(); +} + let idlePass: Promise | undefined; function mountIdle() { return (idlePass ||= (async () => { @@ -1626,6 +1775,8 @@ function deferDone() { if (deferredFrames().length) return; if (deferring) { deferring = false; + edgeIndex = undefined; + (globalThis as any).__scDeferDoneAt = performance.now(); setWriteHook(null); DEFER_EVENTS.forEach(t => document.removeEventListener(t, wakeAt, true)); } @@ -1648,7 +1799,7 @@ let deferring = false; function deferFrame(frame: any) { if (!deferring) { deferring = true; - setWriteHook(mountAllDeferred); + setWriteHook(mountForWrite); DEFER_EVENTS.forEach(t => document.addEventListener(t, wakeAt, true)); } // An event that landed before the frame existed waits in hydration's @@ -1752,6 +1903,15 @@ export function installServerComponents(host: any = getFrameHost(), options?: In // early as the page's `_$HY` allows (when this call precedes the page's // bootstrap, the first documentBoundary re-attempts). installRevealHook(); + // SPIKE (dependency edges): A/B switches read at boot, one build for all + // modes — cookie `sc-defer=0` mounts at adoption (eager), `sc-edges=1` + // turns edges on (signals are named so a write reports which one). + if (typeof document !== "undefined") { + const cookie = document.cookie; + if (/(?:^|; )sc-defer=0/.test(cookie)) deferHydration = false; + edges = deferHydration && /(?:^|; )sc-edges=1/.test(cookie); + if (edges) setSignalIdentities(true); + } if (typeof document !== "undefined" && deferHydration) setDeferDriver(deferFrame); const handler = createServerComponentHandler({ host, diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 57bc9118d..c80dadf91 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -1107,6 +1107,10 @@ export let deferDriver: ((frame: any) => void) | null = null; export function setDeferDriver(driver: ((frame: any) => void) | null): void { deferDriver = driver; } +/** SPIKE debug: occurrences mounted by deferred hydration so far (`__scDeferMounted`). */ +const deferCount = () => { + (globalThis as any).__scDeferMounted = ((globalThis as any).__scDeferMounted || 0) + 1; +}; /** @internal The live frames still owing their adoption-time mounts, outermost first. */ export function deferredFrames(): any[] { return [...liveFrames].filter(f => f.deferred); @@ -1925,6 +1929,7 @@ class FrameImpl { this.#deferWaiting = this.#mountFound(new Map([[id, outer]])) || this.#deferWaiting; if (!this.#mountedSlots.has(id)) return false; this.#deferMounted.set(id, outer); + deferCount(); return true; } @@ -1946,7 +1951,10 @@ class FrameImpl { for (const [id, start] of found) { found.delete(id); this.#deferWaiting = this.#mountFound(new Map([[id, start]])) || this.#deferWaiting; - if (this.#mountedSlots.has(id)) this.#deferMounted.set(id, start); + if (this.#mountedSlots.has(id)) { + this.#deferMounted.set(id, start); + deferCount(); + } if (deadline !== undefined && found.size && performance.now() > deadline) return false; } this.#deferred = false; @@ -1956,6 +1964,29 @@ class FrameImpl { return true; } + /** + * SPIKE (dependency edges): mount one occurrence of THIS frame by id, from + * the walk the slices mount from (taken now if no slice has run yet). + * False when this frame does not owe it (not deferred, not its occurrence, + * or already mounted). + */ + mountOccurrence(id: string): boolean { + if (!this.#deferred) return false; + let found = this.#deferFound; + if (!found) { + found = this.#deferFound = new Map(); + this.#collectSlots(found); + } + const start = found.get(id); + if (!start) return false; + found.delete(id); + this.#deferWaiting = this.#mountFound(new Map([[id, start]])) || this.#deferWaiting; + if (!this.#mountedSlots.has(id)) return false; + this.#deferMounted.set(id, start); + deferCount(); + return true; + } + /** @internal Deferred hydration finished page-wide: release the adoption's hold. */ releaseDeferHold() { const release = this.#deferHold; diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 4b0ccc4a3..577fce21a 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -40,7 +40,8 @@ import { ssrHandleError, ssrSanitizeError, creationStamp, - shareAsyncIterable + shareAsyncIterable, + trackFillReads } from "solid-js/internal"; // EXPERIMENTAL — the frames/server-components surface ships as an @@ -1350,6 +1351,44 @@ function tapFirstYield(iterable) { * the range. Fill liveness is the RECORD's story: arg re-emissions update * the adopted occurrence's props. */ +/** + * SPIKE (dependency edges): a reader list on the wire — one string, runs of + * `prop#n` occurrences folded to `prop#a-b` (`card#0-399 cartSummary#0`). + */ +function packOccurrences(occs: string[]): string { + const out: string[] = []; + let prop = "", + a = -1, + b = -1; + const flush = () => a >= 0 && out.push(prop + "#" + (a === b ? a : a + "-" + b)); + for (const o of occs) { + const m = /^(.*)#(\d+)$/.exec(o); + if (m && m[1] === prop && +m[2] === b + 1 && a >= 0) { + b++; + continue; + } + flush(); + a = b = -1; + if (m) { + prop = m[1]; + a = b = +m[2]; + } else out.push(o); + } + flush(); + return out.join(" "); +} + +/** SPIKE (dependency edges): whether rendered SSR output still holds an async hole. */ +function hasAsyncHoles(node, depth = 0): boolean { + if (!node || typeof node !== "object" || depth > 64) return false; + if (Array.isArray(node)) { + for (const n of node) if (hasAsyncHoles(n, depth + 1)) return true; + return false; + } + if (node.h && node.h.length) return true; + return false; +} + function suppressedFill(render) { const live = sharedConfig.context && sharedConfig.context.liveHoles; if (!live) return render(); @@ -1424,6 +1463,40 @@ export function createDocumentSlotProps( export function createDocumentSlotProps(clientProps, frameId) { const counts = Object.create(null); + // SPIKE (deferred hydration, dependency edges): which occurrences read + // which plain signals they did not create, aggregated per frame as + // `sid -> [occurrence]` and shipped in batches (`sc:deps::`, one + // per synchronous span of fills). `*` lists signals whose readers are not + // tracked (wide: a write mounts everything); `!` lists occurrences whose + // fill left async holes (reads after the fill returned are not tracked: a + // write to any named signal mounts them). + let depsBatch: Record | null = null; + let depsN = 0; + let wideSent = 0; + const depsContext = sharedConfig.context; + const noteDeps = (occurrence: string, reads: Set | null, untracked: boolean) => { + if (!depsContext || !depsContext.serialize) return; + const own = `sc-${frameId}-${occurrence}-`; + const add = (sid: string) => { + if (!depsBatch) { + depsBatch = Object.create(null); + queueMicrotask(() => { + const b: Record = {}; + for (const sid in depsBatch!) b[sid] = packOccurrences(depsBatch![sid]); + depsBatch = null; + const wide: Set | null = (depsContext as any).edgeWide?.s || null; + if (wide && wide.size > wideSent) { + b["*"] = [...wide].slice(wideSent).join(" "); + wideSent = wide.size; + } + depsContext.serialize(`sc:deps:${frameId}:${depsN++}`, b); + }); + } + (depsBatch![sid] ||= []).push(occurrence); + }; + if (reads) for (const sid of reads) if (!sid.startsWith(own)) add(sid); + if (untracked) add("!"); + }; const getters = new Map(); // Repeated keyed calls are one occurrence within a render (see keyedId). const repeats = new Map(); @@ -1756,9 +1829,13 @@ export function createDocumentSlotProps(clientProps, frameId) { const finish = owner => { if (deferred) declare(); try { - const out = suppressedFill(() => - scoped(occurrence, () => range(occurrence, slot(resolved), flip), owner) + const tracked = trackFillReads(() => + suppressedFill(() => + scoped(occurrence, () => range(occurrence, slot(resolved), flip), owner) + ) ); + const out = tracked.result; + noteDeps(occurrence, tracked.reads, hasAsyncHoles(out)); if (context) { // One record shape (A5, server-components-principles.md): the // t=0 document emits the record a stream would — every invoked From 9f2bf4336b3ef88ad11a8184d91ebf55dbf69881 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:28:06 +0000 Subject: [PATCH 2/8] feat(frames): dependency edges are the deferred-hydration default A write during deferred hydration mounts only the slot occurrences the server recorded as readers of the written signal, instead of finishing every pending mount first. The cookie switches and debug globals from the spike are gone; finish-then-apply remains only as the fallback for writes the records cannot vouch for (no signal name, a wide signal, a write while the document streams, a deferred frame without records). - signals: a recompute that made only plain signals (no child owners) restarts their names, so a rerun names them the same way again. - dev only: each deferred mount is watched; a read of a named signal the server did not record for that occurrence warns once per signal (identity drift between server and client creation order). - tests: server record emission; hydrate specs for reader-only mounts, store/wide/derived fallbacks, local-state writes and the drift warning; signal naming in the signals suite. - size: caps raised to measured + 10 B with dated ledger notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/signals/src/core/core.ts | 16 +- packages/signals/src/core/index.ts | 1 + packages/signals/src/index.ts | 3 +- .../signals/tests/signal-identities.test.ts | 95 ++++++++ packages/solid/src/index.ts | 14 +- packages/solid/src/internal.ts | 17 +- packages/solid/src/server/index.ts | 4 +- packages/solid/src/server/signals.ts | 49 ++-- packages/web/frames/src/client.ts | 140 ++++++------ packages/web/frames/src/frame-client.ts | 54 +++-- packages/web/frames/src/frame-sink.ts | 56 ++--- .../dynamic-component-parity-inline.json | 2 +- .../dynamic-component-parity-streamed.json | 2 +- .../__artifacts__/frame-dependency-edges.json | 5 + .../frame-live-document-loaded.json | 2 +- .../frame-live-document-streamed.json | 2 +- .../frame-live-document-switched.json | 2 +- .../frame-nested-fill-context.json | 2 +- ...e-nonlive-document-3666-inline-loaded.json | 2 +- ...nonlive-document-3666-streamed-loaded.json | 2 +- ...nlive-document-3666-streamed-streamed.json | 2 +- .../frame-occlusion-document.json | 2 +- .../multisite-hydration-3889.json | 2 +- .../__artifacts__/welcome-status-loaded.json | 4 +- .../welcome-status-streamed.json | 4 +- .../test/harness/frame-dependency-edges.tsx | 165 ++++++++++++++ .../frame-dependency-edges-derived.spec.tsx | 27 +++ .../frame-dependency-edges-drift.spec.tsx | 28 +++ .../frame-dependency-edges-helpers.tsx | 69 ++++++ .../frame-dependency-edges-local.spec.tsx | 27 +++ .../frame-dependency-edges-store.spec.tsx | 27 +++ .../frame-dependency-edges-wide.spec.tsx | 27 +++ .../hydration/frame-dependency-edges.spec.tsx | 36 +++ .../server/frame-dependency-edges.spec.tsx | 54 +++++ scripts/size/floor-caps.json | 20 +- scripts/size/scenarios.js | 212 ++++++++++++++++-- 36 files changed, 990 insertions(+), 186 deletions(-) create mode 100644 packages/signals/tests/signal-identities.test.ts create mode 100644 packages/web/test/harness/__artifacts__/frame-dependency-edges.json create mode 100644 packages/web/test/harness/frame-dependency-edges.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-derived.spec.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-drift.spec.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-helpers.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-local.spec.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-store.spec.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-wide.spec.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges.spec.tsx create mode 100644 packages/web/test/server/frame-dependency-edges.spec.tsx diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 26b678499..cc970703e 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -366,6 +366,9 @@ export function recompute(el: Computed, create: boolean = false): void { // and own nothing to park. if (isEffect === EFFECT_TRACKED || el._config & CONFIG_STAGED) disposeChildren(el); else if (el._firstChild !== null || el._disposal !== null) parkChildren(el); + // A pass that made only plain signals still restarts their names (the + // server resets every re-run). + else if (sigCounts !== null) sigCounts.delete(el); if (__DEV__) clearSignals(el); } @@ -1689,6 +1692,7 @@ export function pullComputed(owner: Computed, c: Computed): void { } export function read(el: Signal | Computed): T { + if (__DEV__ && readWatch !== null && (el as any)._sid !== undefined) readWatch((el as any)._sid); if (__DEV__ && asyncTailFlights !== 0 && !tracking && untrackDepth === 0) checkPostAwaitRead( el, @@ -2042,7 +2046,7 @@ export function setWriteHook(hook: ((sid?: string) => void) | null): void { writeHook = hook; } -// SPIKE (deferred hydration, dependency edges): plain-signal identities. +// Deferred hydration, dependency edges: plain-signal identities. // While on (`setSignalIdentities`, the server-components client turns it on // before hydrating), a plain signal created under an id-bearing owner is // named `~`, n counting the plain signals created under that @@ -2059,6 +2063,16 @@ export function setSignalIdentities(on: boolean): void { export function resetSignalCount(owner: Owner): void { if (sigCounts !== null) sigCounts.delete(owner); } +/** + * @internal Dev only: called with the identity of every named signal read + * while set — the frames client's identity-drift check watches what a + * deferred occurrence reads as it mounts. Never called in prod builds. + */ +export let readWatch: ((sid: string) => void) | null = null; +/** @internal */ +export function setReadWatch(watch: ((sid: string) => void) | null): void { + if (__DEV__) readWatch = watch; +} /** @internal */ export function identifySignal(node: Signal, owner: Owner | null): void { if (sigCounts === null || !owner) return; diff --git a/packages/signals/src/core/index.ts b/packages/signals/src/core/index.ts index b4fb0a289..a33245e7b 100644 --- a/packages/signals/src/core/index.ts +++ b/packages/signals/src/core/index.ts @@ -11,6 +11,7 @@ export { setSignal, setWriteHook, setSignalIdentities, + setReadWatch, identifySignal, setMemo, markRefresh, diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 50d115043..fcebd1b1c 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -39,7 +39,8 @@ export { */ export { setConsoleFooter } from "./core/index.js"; /** @internal The pre-write hook (`setWriteHook` in core/core.ts); the server-components client's deferred hydration. */ -export { setWriteHook, setSignalIdentities } from "./core/index.js"; +/** @internal Plain-signal identities and the dev read watch (dependency edges; the server-components client). */ +export { setWriteHook, setSignalIdentities, setReadWatch } from "./core/index.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js"; /** * Observe tier (diagnostics channel, attribution hook slot + interaction diff --git a/packages/signals/tests/signal-identities.test.ts b/packages/signals/tests/signal-identities.test.ts new file mode 100644 index 000000000..2c9490174 --- /dev/null +++ b/packages/signals/tests/signal-identities.test.ts @@ -0,0 +1,95 @@ +// Plain-signal identities (deferred hydration's dependency edges). While on, +// a plain signal created under an id-bearing owner is named `~`, n +// counting the plain signals created under the nearest id-bearing owner; +// the count restarts when the owner re-runs, as child ids do. The pre-write +// hook receives the name (none for a store or a derived signal), and the dev +// read watch sees it on every read. +import { afterEach, describe, expect, test } from "vitest"; +import { setReadWatch, setSignalIdentities, setWriteHook } from "../src/core/index.js"; +import { createMemo, createRoot, createSignal, createStore, flush } from "../src/index.js"; + +afterEach(() => { + setWriteHook(null); + setReadWatch(null); + setSignalIdentities(false); +}); + +function hookedWrites() { + const writes: (string | undefined)[] = []; + setWriteHook(sid => writes.push(sid)); + return writes; +} + +describe("plain-signal identities", () => { + test("named per id-bearing owner, in creation order; the hook receives the name", () => { + setSignalIdentities(true); + const writes = hookedWrites(); + const [setA, setB, setD, setS] = createRoot( + () => + [ + createSignal(0)[1], + createSignal(0)[1], + createSignal(() => 1)[1], + createStore({ n: 0 })[1] + ] as const, + { id: "r" } + ); + setA(1); + setB(1); + setD(2); + setS(s => void (s.n = 1)); + // The derived signal and the store write carry no name. + expect(writes).toEqual(["r~0", "r~1", undefined, undefined]); + }); + + test("the count restarts when the owner re-runs", () => { + setSignalIdentities(true); + const writes = hookedWrites(); + let set: ((v: number) => void) | undefined; + const [n, setN] = createSignal(0); + createRoot( + () => { + createMemo(() => { + n(); + [, set] = createSignal(0); + }); + }, + { id: "m" } + ); + flush(); + set!(1); + setN(1); + flush(); + set!(2); + // The signal the second pass made has the first one's name. + const named = writes.filter(w => w !== undefined); + expect(named.length).toBe(2); + expect(named[0]).toBe(named[1]); + }); + + test("off: no names; signals outside an id-bearing owner are never named", () => { + const writes = hookedWrites(); + const set = createRoot(() => createSignal(0)[1], { id: "off" }); + set(1); + setSignalIdentities(true); + const [, outside] = createSignal(0); + outside(1); + expect(writes).toEqual([undefined, undefined]); + }); + + test("dev: the read watch sees named reads", () => { + setSignalIdentities(true); + const reads: string[] = []; + createRoot( + () => { + const [a] = createSignal(0); + setReadWatch(sid => reads.push(sid)); + a(); + setReadWatch(null); + a(); + }, + { id: "w" } + ); + expect(reads).toEqual(["w~0"]); + }); +}); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index a35be965b..3160844c3 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -184,11 +184,15 @@ export function creationStamp(): number { } /** @internal A callback run before every signal or store write lands (deferred hydration). */ export { setWriteHook } from "@solidjs/signals"; -/** @internal SPIKE (dependency edges): name plain signals so a write reports which one. */ -export { setSignalIdentities } from "@solidjs/signals"; -/** @internal SPIKE (dependency edges): server-only read tracking; the client runs `fn`. */ -export function trackFillReads(fn: () => T): { result: T; reads: Set | null } { - return { result: fn(), reads: null }; +/** @internal Dependency edges: name plain signals so a write reports which one; dev read watch. */ +export { setSignalIdentities, setReadWatch } from "@solidjs/signals"; +/** @internal Dependency edges: server-only read tracking; the client runs `fn`. */ +export function trackFillReads(fn: () => T): { + result: T; + reads: Set | null; + untracked: boolean; +} { + return { result: fn(), reads: null, untracked: false }; } /** @internal */ export function inServerComponentScope(): boolean { diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index 6508ed04a..c9219f021 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -130,15 +130,20 @@ export const creationStamp: () => number = core.creationStamp; * what it left unmounted. Server: no-op. */ export const setWriteHook: (hook: ((sid?: string) => void) | null) => void = core.setWriteHook; -/** SPIKE (dependency edges). Client: name plain signals (`~`) from now on. Server: no-op. */ +/** Dependency edges. Client: name plain signals (`~`) from now on. Server: no-op. */ export const setSignalIdentities: (on: boolean) => void = core.setSignalIdentities; +/** Dependency edges, dev only. Client: watch the identities of named signals read. Server: no-op. */ +export const setReadWatch: (watch: ((sid: string) => void) | null) => void = core.setReadWatch; /** - * SPIKE (dependency edges). Server: run a slot fill, returning the plain-signal - * identities it read (through memos too), and those any projection read - * (`wide`: their readers are unknown). Client: runs it. + * Dependency edges. Server: run a slot fill, returning the plain-signal + * identities it read (through memos too) and whether it ran a computation + * whose reads the server cannot see. Client: runs it. */ -export const trackFillReads: (fn: () => T) => { result: T; reads: Set | null } = - core.trackFillReads; +export const trackFillReads: (fn: () => T) => { + result: T; + reads: Set | null; + untracked: boolean; +} = core.trackFillReads; /** Server: the live projection trace behind a value, if any. Client: `undefined`. */ export const getProjectionTrace: ( diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 587dab4bd..96757aeb4 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -162,8 +162,10 @@ export { export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; /** @internal Client-only: the server has no writes to observe. */ export function setWriteHook(_hook: ((sid?: string) => void) | null): void {} -/** @internal SPIKE (dependency edges): client-only. */ +/** @internal Dependency edges: client-only. */ export function setSignalIdentities(_on: boolean): void {} +/** @internal Dependency edges: client-only. */ +export function setReadWatch(_watch: ((sid: string) => void) | null): void {} /** @internal */ export { ssrHandleError, ssrScope } from "./hydration.js"; // After the runtime modules above, so this import adds no edge to the module diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 9664ed3f5..7d29bf9f2 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -147,7 +147,7 @@ interface SSROwner { _parent: SSROwner | null; _context: Record; _childCount: number; - /** SPIKE (dependency edges): plain signals created under this owner, reset with `_childCount`. */ + /** Plain signals created under this owner (dependency-edge identities), reset with `_childCount`. */ _sigCount: number; _firstChild: SSROwner | null; _nextSibling: SSROwner | null; @@ -1238,8 +1238,8 @@ function warnServerWrite(category: "signal" | "store" | "optimistic"): void { let asyncReads = 0; // --------------------------------------------------------------------------- -// SPIKE (deferred hydration, dependency edges): plain-signal identities and -// read tracking. A plain signal created under an id-bearing owner is named +// Deferred hydration, dependency edges: plain-signal identities and read +// tracking. A plain signal created under an id-bearing owner is named // `~` (n counts plain signals under that owner, reset with its // child counter), as the client's signals core names it. A read reports the // name to the current sink: a slot fill's (`trackFillReads`), a memo's — a @@ -1247,8 +1247,16 @@ let asyncReads = 0; // lands on its sources — or the request's WIDE sink, for reads whose // readers are not tracked (a projection's compute): a write to a wide // signal mounts everything left. -type ReadSink = { s: Set | null; wide?: true }; +type ReadSink = { s: Set | null; wide?: true; untracked?: true }; let readSink: ReadSink | null = null; +// The innermost slot fill being tracked (`trackFillReads`), for computations +// whose reads the server cannot see: a client-source memo or effect, a +// tracked effect (its compute never runs here). The fill's edges are then +// incomplete, and it mounts on every named write. +let fillSink: ReadSink | null = null; +function markFillUntracked(): void { + if (fillSink !== null) fillSink.untracked = true; +} function reportRead(sid: string): void { const sink = readSink!; (sink.s ||= new Set()).add(sid); @@ -1266,15 +1274,26 @@ function signalIdentity(owner: SSROwner | null): string | undefined { const id = materializeId(o); return id + "~" + o._sigCount++; } -/** @internal SPIKE: run `fn`, returning its result and the plain-signal identities it read. */ -export function trackFillReads(fn: () => T): { result: T; reads: Set | null } { +/** + * @internal Run a slot fill, returning its result, the plain-signal + * identities it read, and whether it ran a computation whose reads the + * server cannot see (`untracked`). + */ +export function trackFillReads(fn: () => T): { + result: T; + reads: Set | null; + untracked: boolean; +} { const prev = readSink; + const prevFill = fillSink; const sink: ReadSink = { s: null }; - readSink = sink; + readSink = fillSink = sink; try { - return { result: fn(), reads: sink.s }; + const result = fn(); + return { result, reads: sink.s, untracked: !!sink.untracked }; } finally { readSink = prev; + fillSink = prevFill; } } /** Run `fn` with its reads reported to the request's wide sink (`context.edgeWide`). */ @@ -1326,7 +1345,7 @@ export function createSignal( ]; } // Plain value form — no ID allocation (IDs are only for owners/computations). - // SPIKE (dependency edges): an identity, reported on read (see `readSink`). + // Dependency edges: an identity, reported on read (see `readSink`). const sid = signalIdentity(currentOwner); return [ sid !== undefined @@ -1438,7 +1457,7 @@ export function createMemo( // after another request may have set its own, and everything the pass // creates — a sync memo it pulls, a slot's content — would take that // render's ids (#3972). - // SPIKE (dependency edges): what the last compute read, re-reported on read. + // Dependency edges: what the last compute read, re-reported on read. let memoReads: Set | null = null; const run = () => { resetOwnerForRerun(owner); @@ -1512,6 +1531,7 @@ export function createMemo( const ssrSource = options?.ssrSource; if (ssrSource === "client") { // Skip computation. Owner created for ID parity. + markFillUntracked(); comp.computed = true; if (loadingState) { // Client-source with commit #0: markup flushes the loading value; the @@ -1604,7 +1624,7 @@ function createSyncMemo( let epoch: number | undefined; // See ServerComputation.moving. let moving = false; - // SPIKE (dependency edges): what the last compute read, re-reported on read. + // Dependency edges: what the last compute read, re-reported on read. let memoReads: Set | null = null; function pull(): T | undefined { @@ -2409,6 +2429,7 @@ function serverEffect( ): void { const ssrSource = options?.ssrSource; if (ssrSource === "client") { + markFillUntracked(); createOwner(); return; } @@ -2528,7 +2549,9 @@ export function createTrackedEffect( compute: () => void | (() => void), options?: { name?: string } ): void { - // No-op on server, but allocate computation ID + // No-op on server, but allocate computation ID. Its reads are the + // client's only, so a slot fill running one has edges the server misses. + markFillUntracked(); const o = getOwner(); if (o?.id != null) getNextChildId(o); } @@ -2832,7 +2855,7 @@ export function createProjection( options?: ServerProjectionOptions ): Store { const ctx = sharedConfig.context; - // SPIKE (dependency edges): a projection's readers are not tracked (store + // Dependency edges: a projection's readers are not tracked (store // reads report nothing), so the signals its compute reads are WIDE. if (ctx) { const compute = fn; diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 85e2754f9..ecdb180ef 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -50,6 +50,7 @@ import { prepareTier, releaseDeferHolds, setDeferDriver, + setDeferMountWatch, tierLoaders, tierLoads, type TierModule @@ -64,6 +65,7 @@ import { } from "./frame-transport.js"; import { createLoadingBoundary, + setReadWatch, setSignalIdentities, setWriteHook, sharedConfig @@ -1599,23 +1601,23 @@ function mountAllDeferred() { deferDone(); } -// SPIKE (deferred hydration, dependency edges). The server ships, per -// document frame, which occurrences read which plain signals they did not -// create (`sc:deps::` batches of `sid -> [occurrence]`; `*` wide -// signals, `!` occurrences with untracked reads). With edges on, a write to +// Dependency edges. The server ships, per document frame, which occurrences +// read which plain signals they did not create (`sc:deps::` +// batches, `sid -> "card#0-399 cart#0"`; `*`: wide signals, whose readers it +// could not track; `!`: occurrences with reads it could not see). A write to // a named signal mounts only the unmounted occurrences that read it — each -// with the occurrences enclosing it first, as an event would — instead of -// everything left. Anything the records cannot vouch for falls back to -// mounting everything: a write with no name (a store, a derived or -// optimistic signal, an unnamed owner), a wide signal, a write while the -// document is still streaming (records may not have arrived), or a deferred -// frame under a root that shipped no records. -let edges = false; +// with the occurrences enclosing it first, as an event would — before the +// write applies; a named signal no record names mounts nothing (no +// unmounted occurrence read it). Everything the records cannot vouch for +// mounts everything left: a write with no name (a store, a derived or +// optimistic signal, a signal of an owner without an id), a wide signal, a +// write while the document still streams (records may not have arrived), or +// a deferred frame under a root that shipped no records. const edgeRoots = new Map(); type EdgeIndex = { - readers: Map; + readers: Map; wide: Set; - untracked: [string, string][]; + untracked: [site: string, occurrence: string][]; sites: Set; starts: Map>; }; @@ -1648,7 +1650,7 @@ function buildEdgeIndex(): EdgeIndex | undefined { } return idx; } -/** `card#0-2 cart#0` -> `card#0 card#1 card#2 cart#0` (see the sink's packOccurrences). */ +/** `card#0-2 cart#0` -> `card#0 card#1 card#2 cart#0` (the sink's `packOccurrences`). */ function unpackOccurrences(packed: string): string[] { const out: string[] = []; for (const part of packed.split(" ")) { @@ -1658,6 +1660,7 @@ function unpackOccurrences(packed: string): string[] { } return out; } +/** An occurrence's start marker under its root (a nested one, found in the document). */ function startOf(idx: EdgeIndex, site: string, occ: string): Comment | undefined { let m = idx.starts.get(site); if (!m) { @@ -1682,30 +1685,13 @@ function edgesCover(idx: EdgeIndex): boolean { } return true; } -function mountAll(sid?: string) { - const g = globalThis as any; - const t0 = performance.now(); - const c0 = g.__scDeferMounted || 0; - mountAllDeferred(); - (g.__scEdgeMounts ||= []).push([ - sid ?? null, - "all", - performance.now() - t0, - 0, - g.__scDeferMounted - c0, - c0 - ]); -} function mountForWrite(sid?: string) { if (mounting || (sharedConfig as any).hydrating) return; - if (!edges || sid === undefined || document.readyState === "loading") return mountAll(sid); - const t0 = performance.now(); + if (sid === undefined || document.readyState === "loading") return mountAllDeferred(); const idx = (edgeIndex ||= buildEdgeIndex()); - if (!idx || idx.wide.has(sid) || !edgesCover(idx)) return mountAll(sid); + if (!idx || idx.wide.has(sid) || !edgesCover(idx)) return mountAllDeferred(); const readers = idx.readers.get(sid); if (!readers && !idx.untracked.length) return; - const t1 = performance.now(); - let n = 0; mounting = true; try { for (const list of [readers || [], idx.untracked]) @@ -1714,34 +1700,64 @@ function mountForWrite(sid?: string) { // inside an occurrence not mounted yet (a region's fill) is found in // the document and mounted with its enclosing ones first. const root = edgeRoots.get(site); - let done = false; - for (const f of deferredFrames()) - if (root && root.contains(f.el) && f.mountOccurrence(occ)) { - done = true; - n++; - break; - } - if (done) continue; + if (root && deferredFrames().some(f => root.contains(f.el) && f.mountOccurrence(occ))) + continue; const start = startOf(idx, site, occ); const target = start && (start.nextSibling || start); - if (!target) continue; - for (let f; (f = liveFrameAt(target)) && f.mountEnclosing(target); ) n++; + if (target) for (let f; (f = liveFrameAt(target)) && f.mountEnclosing(target); ); } } finally { mounting = false; } - const g = globalThis as any; - (g.__scEdgeMounts ||= []).push([ - sid, - "edges", - performance.now() - t0, - t1 - t0, - n, - g.__scDeferMounted - n - ]); deferDone(); } +/** + * Dev: identity drift. A deferred occurrence mounting reads a named signal + * it did not create that its record does not list: the client named that + * signal differently from the server (a plain signal created on one side + * only, under the same owner, ahead of it), so a write to it would mount + * the wrong occurrences. Warned once per signal. + */ +let driftReads: Map> | undefined; +const driftWarned = new Set(); +function watchDeferredMount(site: string, occ: string, mount: () => void) { + const reads = new Set(); + setReadWatch(sid => reads.add(sid)); + try { + mount(); + } finally { + setReadWatch(null); + } + const idx = (edgeIndex ||= buildEdgeIndex()); + if (!idx || !idx.sites.has(site) || idx.untracked.some(([s, o]) => s === site && o === occ)) + return; + if (!driftReads) { + driftReads = new Map(); + for (const [sid, list] of idx.readers) + for (const [s, o] of list) { + const key = s + " " + o; + let set = driftReads.get(key); + if (!set) driftReads.set(key, (set = new Set())); + set.add(sid); + } + } + const own = `sc-${site}-${occ}-`; + const listed = driftReads.get(site + " " + occ); + for (const sid of reads) { + if (sid.startsWith(own) || listed?.has(sid) || driftWarned.has(sid)) continue; + driftWarned.add(sid); + console.warn( + `[deferred hydration] The slot occurrence "${occ}" read the signal "${sid}" while mounting, ` + + `but the server did not record that read. The client and the server name this signal ` + + `differently: a plain signal is created on only one side, under the same component or ` + + `owner, before it (code that branches on isServer, or a client-only helper). A write to ` + + `it would mount the wrong occurrences. Create such signals after the shared ones, or ` + + `under their own owner.` + ); + } +} + let idlePass: Promise | undefined; function mountIdle() { return (idlePass ||= (async () => { @@ -1775,8 +1791,7 @@ function deferDone() { if (deferredFrames().length) return; if (deferring) { deferring = false; - edgeIndex = undefined; - (globalThis as any).__scDeferDoneAt = performance.now(); + edgeIndex = driftReads = undefined; setWriteHook(null); DEFER_EVENTS.forEach(t => document.removeEventListener(t, wakeAt, true)); } @@ -1793,6 +1808,8 @@ let deferHydration = true; export function setDeferredHydration(on: boolean): void { deferHydration = on; setDeferDriver(on && typeof document !== "undefined" ? deferFrame : null); + setSignalIdentities(on && typeof document !== "undefined"); + if (IS_DEV) setDeferMountWatch(on ? watchDeferredMount : null); } let deferring = false; @@ -1903,16 +1920,13 @@ export function installServerComponents(host: any = getFrameHost(), options?: In // early as the page's `_$HY` allows (when this call precedes the page's // bootstrap, the first documentBoundary re-attempts). installRevealHook(); - // SPIKE (dependency edges): A/B switches read at boot, one build for all - // modes — cookie `sc-defer=0` mounts at adoption (eager), `sc-edges=1` - // turns edges on (signals are named so a write reports which one). - if (typeof document !== "undefined") { - const cookie = document.cookie; - if (/(?:^|; )sc-defer=0/.test(cookie)) deferHydration = false; - edges = deferHydration && /(?:^|; )sc-edges=1/.test(cookie); - if (edges) setSignalIdentities(true); + if (typeof document !== "undefined" && deferHydration) { + setDeferDriver(deferFrame); + // Deferred hydration names plain signals, so a write reports which one + // (dependency edges); before hydration creates any. + setSignalIdentities(true); + if (IS_DEV) setDeferMountWatch(watchDeferredMount); } - if (typeof document !== "undefined" && deferHydration) setDeferDriver(deferFrame); const handler = createServerComponentHandler({ host, // ONE mount component per function, and it IS the document placeholder: diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index c80dadf91..1d2bd57ed 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -1107,10 +1107,18 @@ export let deferDriver: ((frame: any) => void) | null = null; export function setDeferDriver(driver: ((frame: any) => void) | null): void { deferDriver = driver; } -/** SPIKE debug: occurrences mounted by deferred hydration so far (`__scDeferMounted`). */ -const deferCount = () => { - (globalThis as any).__scDeferMounted = ((globalThis as any).__scDeferMounted || 0) + 1; -}; +/** + * @internal Dev only: wraps every deferred occurrence mount, with the claim + * scope (the document frame's site) and the occurrence id — the frames + * client's identity-drift check (client.ts). + */ +export let deferMountWatch: ((site: string, id: string, mount: () => void) => void) | null = null; +/** @internal */ +export function setDeferMountWatch( + watch: ((site: string, id: string, mount: () => void) => void) | null +): void { + deferMountWatch = watch; +} /** @internal The live frames still owing their adoption-time mounts, outermost first. */ export function deferredFrames(): any[] { return [...liveFrames].filter(f => f.deferred); @@ -1926,10 +1934,18 @@ class FrameImpl { } const id = outer && slotStartId(outer); if (!id || this.#mountedSlots.has(id)) return false; - this.#deferWaiting = this.#mountFound(new Map([[id, outer]])) || this.#deferWaiting; + return this.#mountOne(id, outer); + } + + /** Deferred hydration: mount one owed occurrence. Whether it mounted. */ + #mountOne(id: string, start: Comment): boolean { + const mount = () => { + this.#deferWaiting = this.#mountFound(new Map([[id, start]])) || this.#deferWaiting; + }; + if (deferMountWatch) deferMountWatch(this.#options.claimScope ?? this.#options.id, id, mount); + else mount(); if (!this.#mountedSlots.has(id)) return false; - this.#deferMounted.set(id, outer); - deferCount(); + this.#deferMounted.set(id, start); return true; } @@ -1950,11 +1966,10 @@ class FrameImpl { } for (const [id, start] of found) { found.delete(id); - this.#deferWaiting = this.#mountFound(new Map([[id, start]])) || this.#deferWaiting; - if (this.#mountedSlots.has(id)) { - this.#deferMounted.set(id, start); - deferCount(); - } + // One an event mounted already takes the sync's update path. + if (this.#mountedSlots.has(id)) + this.#deferWaiting = this.#mountFound(new Map([[id, start]])) || this.#deferWaiting; + else this.#mountOne(id, start); if (deadline !== undefined && found.size && performance.now() > deadline) return false; } this.#deferred = false; @@ -1965,13 +1980,15 @@ class FrameImpl { } /** - * SPIKE (dependency edges): mount one occurrence of THIS frame by id, from - * the walk the slices mount from (taken now if no slice has run yet). - * False when this frame does not owe it (not deferred, not its occurrence, - * or already mounted). + * Deferred hydration, dependency edges: mount one occurrence of THIS frame + * by id — a write's reader — from the walk the slices mount from (taken now + * if no slice has run yet). True when this frame owes it: mounted now, or + * by an event already. False when it is not this frame's (an occurrence in + * a region of one not mounted yet). */ mountOccurrence(id: string): boolean { if (!this.#deferred) return false; + if (this.#mountedSlots.has(id)) return true; let found = this.#deferFound; if (!found) { found = this.#deferFound = new Map(); @@ -1980,10 +1997,7 @@ class FrameImpl { const start = found.get(id); if (!start) return false; found.delete(id); - this.#deferWaiting = this.#mountFound(new Map([[id, start]])) || this.#deferWaiting; - if (!this.#mountedSlots.has(id)) return false; - this.#deferMounted.set(id, start); - deferCount(); + this.#mountOne(id, start); return true; } diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 577fce21a..7c6605ce3 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -1352,7 +1352,7 @@ function tapFirstYield(iterable) { * the adopted occurrence's props. */ /** - * SPIKE (dependency edges): a reader list on the wire — one string, runs of + * Dependency edges: a reader list on the wire — one string, runs of * `prop#n` occurrences folded to `prop#a-b` (`card#0-399 cartSummary#0`). */ function packOccurrences(occs: string[]): string { @@ -1378,7 +1378,7 @@ function packOccurrences(occs: string[]): string { return out.join(" "); } -/** SPIKE (dependency edges): whether rendered SSR output still holds an async hole. */ +/** Dependency edges: whether rendered SSR output still holds an async hole. */ function hasAsyncHoles(node, depth = 0): boolean { if (!node || typeof node !== "object" || depth > 64) return false; if (Array.isArray(node)) { @@ -1463,13 +1463,15 @@ export function createDocumentSlotProps( export function createDocumentSlotProps(clientProps, frameId) { const counts = Object.create(null); - // SPIKE (deferred hydration, dependency edges): which occurrences read - // which plain signals they did not create, aggregated per frame as - // `sid -> [occurrence]` and shipped in batches (`sc:deps::`, one - // per synchronous span of fills). `*` lists signals whose readers are not - // tracked (wide: a write mounts everything); `!` lists occurrences whose - // fill left async holes (reads after the fill returned are not tracked: a - // write to any named signal mounts them). + // Deferred hydration, dependency edges: which occurrences read which + // plain signals they did not create, aggregated per frame as + // `sid -> "card#0-399 cart#0"` and shipped in batches + // (`sc:deps::`, one per synchronous span of fills; the first + // always, empty or not, so the client knows the frame's records exist). + // `*` lists signals whose readers are not tracked (wide: a write mounts + // everything); `!` lists occurrences with reads the server could not see + // (async holes left by the fill, client-source or tracked effects): a + // write to any named signal mounts them. let depsBatch: Record | null = null; let depsN = 0; let wideSent = 0; @@ -1477,25 +1479,25 @@ export function createDocumentSlotProps(clientProps, frameId) { const noteDeps = (occurrence: string, reads: Set | null, untracked: boolean) => { if (!depsContext || !depsContext.serialize) return; const own = `sc-${frameId}-${occurrence}-`; - const add = (sid: string) => { - if (!depsBatch) { - depsBatch = Object.create(null); - queueMicrotask(() => { - const b: Record = {}; - for (const sid in depsBatch!) b[sid] = packOccurrences(depsBatch![sid]); - depsBatch = null; - const wide: Set | null = (depsContext as any).edgeWide?.s || null; - if (wide && wide.size > wideSent) { - b["*"] = [...wide].slice(wideSent).join(" "); - wideSent = wide.size; - } + const batch = () => { + if (depsBatch) return depsBatch; + queueMicrotask(() => { + const b: Record = {}; + for (const sid in depsBatch!) b[sid] = packOccurrences(depsBatch![sid]); + depsBatch = null; + const wide: Set | null = (depsContext as any).edgeWide?.s || null; + if (wide && wide.size > wideSent) { + b["*"] = [...wide].slice(wideSent).join(" "); + wideSent = wide.size; + } + if (depsN === 0 || Object.keys(b).length) depsContext.serialize(`sc:deps:${frameId}:${depsN++}`, b); - }); - } - (depsBatch![sid] ||= []).push(occurrence); + }); + return (depsBatch = Object.create(null) as Record); }; - if (reads) for (const sid of reads) if (!sid.startsWith(own)) add(sid); - if (untracked) add("!"); + const b = batch(); + if (reads) for (const sid of reads) if (!sid.startsWith(own)) (b[sid] ||= []).push(occurrence); + if (untracked) (b["!"] ||= []).push(occurrence); }; const getters = new Map(); // Repeated keyed calls are one occurrence within a render (see keyedId). @@ -1835,7 +1837,7 @@ export function createDocumentSlotProps(clientProps, frameId) { ) ); const out = tracked.result; - noteDeps(occurrence, tracked.reads, hasAsyncHoles(out)); + noteDeps(occurrence, tracked.reads, tracked.untracked || hasAsyncHoles(out)); if (context) { // One record shape (A5, server-components-principles.md): the // t=0 document emits the record a stream would — every invoked diff --git a/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json b/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json index d65376f45..5a93abd20 100644 --- a/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json +++ b/packages/web/test/harness/__artifacts__/dynamic-component-parity-inline.json @@ -1,5 +1,5 @@ { "name": "dynamic-component-parity-inline", - "shell": "

client

note v1

", + "shell": "

client

note v1

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json index 5dbbb2f10..eceb2208e 100644 --- a/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json +++ b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json @@ -1,5 +1,5 @@ { "name": "dynamic-component-parity-streamed", "shell": "

client

shell-fallback

", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-dependency-edges.json b/packages/web/test/harness/__artifacts__/frame-dependency-edges.json new file mode 100644 index 000000000..8ebd691a2 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/frame-dependency-edges.json @@ -0,0 +1,5 @@ +{ + "name": "frame-dependency-edges", + "shell": "
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json index 18d144f8f..955ebcc6e 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-loaded", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json index bdc33afdb..15929c2fa 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-streamed", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json index a2021a9d9..e62c714b7 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-switched", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json b/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json index 6087b6587..9e8a87c64 100644 --- a/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json +++ b/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json @@ -1,5 +1,5 @@ { "name": "frame-nested-fill-context", - "shell": "

", + "shell": "

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json index 51bbf11ae..50f9571d4 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-inline-loaded", - "shell": "

note v1

", + "shell": "

note v1

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json index 515cd5f6a..08fcfec90 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-streamed-loaded", "shell": "

shell-fallback

", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json index 60d1af5ae..28c0bac38 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-streamed-streamed", "shell": "

shell-fallback

", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-occlusion-document.json b/packages/web/test/harness/__artifacts__/frame-occlusion-document.json index 9d28469a1..72ccb305c 100644 --- a/packages/web/test/harness/__artifacts__/frame-occlusion-document.json +++ b/packages/web/test/harness/__artifacts__/frame-occlusion-document.json @@ -1,5 +1,5 @@ { "name": "frame-occlusion-document", - "shell": "
  • Title alpha
  • Title bravo

    Excerpt bravo

", + "shell": "
  • Title alpha
  • Title bravo

    Excerpt bravo

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/multisite-hydration-3889.json b/packages/web/test/harness/__artifacts__/multisite-hydration-3889.json index c83103423..4638b116b 100644 --- a/packages/web/test/harness/__artifacts__/multisite-hydration-3889.json +++ b/packages/web/test/harness/__artifacts__/multisite-hydration-3889.json @@ -1,5 +1,5 @@ { "name": "multisite-hydration-3889", - "shell": "", + "shell": "", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json index a6646ffb2..c1bc8bf55 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json @@ -1,5 +1,5 @@ { "name": "welcome-status-loaded", - "shell": "
1…
", - "rest": "" + "shell": "
1…
", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json index 455132146..27ba8f8dc 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json @@ -1,5 +1,5 @@ { "name": "welcome-status-streamed", - "shell": "
1…
", - "rest": "" + "shell": "
1…
", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/frame-dependency-edges.tsx b/packages/web/test/harness/frame-dependency-edges.tsx new file mode 100644 index 000000000..958e5e06b --- /dev/null +++ b/packages/web/test/harness/frame-dependency-edges.tsx @@ -0,0 +1,165 @@ +/** + * @jsxImportSource @solidjs/web + * + * Dependency edges under deferred hydration. The route shell owns shared + * state and provides it through context; the server component places slot + * fills that read some of it: + * - four `item` fills read `currency` and their own click counter; + * - `cart` reads `cart`; + * - `store` reads a store (writes to a store carry no signal name); + * - `wide` reads a projection derived from `wideSource` (a projection's + * readers are not tracked, so `wideSource` is wide); + * - `derived` reads a writable derived signal (no name either); + * - `late` runs a client-source memo over `cart` (a read the server never + * sees), so it mounts on every named write. + * Every fill notes its mount in `mounted`. + * + * `drift` (client only) creates one extra plain signal in the shell ahead + * of the shared ones, so the client names them differently from the server. + * + * Shared by test/server/frame-dependency-edges.spec.tsx (writes the + * artifact) and test/hydration/frame-dependency-edges.spec.tsx. + */ +import { + createContext, + createMemo, + createProjection, + createSignal, + createStore, + Loading, + untrack, + useContext, + type Accessor, + type Setter +} from "solid-js"; +import { dynamic, type JSX } from "@solidjs/web"; + +export const FID = "frame-edges/page"; +export const ARGS = ["page"]; +export const ITEMS = ["a", "b", "c", "d"]; + +export type Shop = { + cart: Accessor; + setCart: Setter; + currency: Accessor; + setCurrency: Setter; + store: { n: number }; + setStore: (fn: (s: { n: number }) => void) => void; + setWideSource: Setter; + wide: { v: number }; + derived: Accessor; + setDerived: Setter; +}; +export const ShopCtx = createContext(); + +/** Each fill mount, by name; the last shell's state, for the test's writes. */ +export const mounted: string[] = []; +export let shop: Shop; + +export function Shell(props: { drift?: boolean; children: JSX.Element }) { + if (props.drift) createSignal("client only"); + const [cart, setCart] = createSignal(0); + const [currency, setCurrency] = createSignal("USD"); + const [wideSource, setWideSource] = createSignal(1); + const [store, setStore] = createStore({ n: 0 }); + const wide = createProjection<{ v: number }>( + d => { + d.v = wideSource(); + }, + { v: 0 } + ); + const [derived, setDerived] = createSignal(() => 0); + shop = { + cart, + setCart, + currency, + setCurrency, + store, + setStore, + setWideSource, + wide, + derived, + setDerived + }; + return {props.children}; +} + +export function Item(props: { id: string }) { + const s = useContext(ShopCtx)!; + const [clicks, setClicks] = createSignal(0); + mounted.push(`item:${untrack(() => props.id)}`); + return ( + + ); +} +export function Cart() { + const s = useContext(ShopCtx)!; + mounted.push("cart"); + return

cart:{s.cart()}

; +} +export function StoreView() { + const s = useContext(ShopCtx)!; + mounted.push("store"); + return

store:{s.store.n}

; +} +export function WideView() { + const s = useContext(ShopCtx)!; + mounted.push("wide"); + return

wide:{s.wide.v}

; +} +export function DerivedView() { + const s = useContext(ShopCtx)!; + mounted.push("derived"); + return

derived:{s.derived()}

; +} +export function Late() { + const s = useContext(ShopCtx)!; + const doubled = createMemo(() => s.cart() * 2, { ssrSource: "client", loadingValue: 0 } as any); + mounted.push("late"); + return

late:{doubled()}

; +} + +/** The page: the server component under a `Loading` inside the shell, mounted through `dynamic`. */ +export function makeApp(source: () => any, options: { drift?: boolean } = {}) { + return () => { + const Page = dynamic(source); + return ( + + shell-fallback

}> + } + cart={() => } + store={() => } + wide={() => } + derived={() => } + late={() => } + /> +
+
+ ); + }; +} + +/** The server component: the four items, then the cart sidebar's widgets. */ +export function makePageComponent() { + return (props: { item: any; cart: any; store: any; wide: any; derived: any; late: any }) => ( +
+
    + {ITEMS.map(id => ( +
  • + +
  • + ))} +
+ +
+ ); +} diff --git a/packages/web/test/hydration/frame-dependency-edges-derived.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-derived.spec.tsx new file mode 100644 index 000000000..618056402 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-derived.spec.tsx @@ -0,0 +1,27 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write the records cannot vouch for — +// a derived signal (no name) — mounts every occurrence left before it applies, as deferred +// hydration did before edges. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { shop } from "../harness/frame-dependency-edges.jsx"; +import { ALL, bootEdges, deferredHydration } from "./frame-dependency-edges-helpers.jsx"; + +deferredHydration(); + +describe("document face — dependency edges — deferred hydrate, fallback", () => { + test("a write to a derived signal (no name) mounts every occurrence left first", () => { + const page = bootEdges(); + shop.setCart(1); + expect(page.seen()).toEqual(["cart", "late"]); + shop.setDerived(3); + expect(page.seen()).toEqual(ALL); + flush(); + expect(page.text("derived")).toBe("derived:3"); + expect(page.text("item-a")).toBe("a:USD:0"); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges-drift.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-drift.spec.tsx new file mode 100644 index 000000000..d2822a834 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-drift.spec.tsx @@ -0,0 +1,28 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half, dev: a client that names a shared signal +// differently from the server (one extra client-only plain signal ahead of +// the shared ones in the shell) is warned about when an occurrence mounting +// reads a shared signal its record does not list. +import { describe, expect, test } from "vitest"; +import { shop } from "../harness/frame-dependency-edges.jsx"; +import { bootEdges, deferredHydration } from "./frame-dependency-edges-helpers.jsx"; + +deferredHydration(); + +describe("document face — dependency edges — deferred hydrate, identity drift", () => { + test("dev: a shared signal named differently on the client is warned about", () => { + const page = bootEdges({ drift: true }); + // The client's `cart` carries the server's name for `currency`. + shop.setCart(1); + // The wrong occurrences mounted, and the first one to read a shared + // signal the server did not record for it says so — once. + expect(page.seen()).toContain("item:a"); + expect(page.seen()).not.toContain("cart"); + const drift = page.warnings.filter(w => w.includes("the server did not record that read")); + expect(drift).toHaveLength(1); + expect(drift[0]).toContain('"item#0"'); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges-helpers.tsx b/packages/web/test/hydration/frame-dependency-edges-helpers.tsx new file mode 100644 index 000000000..ff69cab51 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-helpers.tsx @@ -0,0 +1,69 @@ +/** + * @jsxImportSource @solidjs/web + * + * Shared by the frame-dependency-edges*.spec.tsx hydrate specs: boots the + * document test/server/frame-dependency-edges.spec.tsx rendered and + * hydrates it under deferred hydration. One page per spec file: the frames + * client's boundary index and claim set are module state. + * + * Writes are made synchronously after the boot: the idle pass waits for a + * timer, so every occurrence is still unmounted when they land. + */ +import { afterAll, beforeAll, vi } from "vitest"; +import { hydrate } from "@solidjs/web"; +import { installServerComponents, setDeferredHydration } from "../../frames/src/client.js"; +import { createServerReference } from "../../server-functions/src/client.js"; +import { makeHost } from "../lifecycle-matrix/harness.js"; +import { ARGS, FID, makeApp, mounted } from "../harness/frame-dependency-edges.jsx"; +import { applyChunk, loadArtifact } from "./frame-live-document-helpers.js"; + +export const ALL = [ + "cart", + "derived", + "item:a", + "item:b", + "item:c", + "item:d", + "late", + "store", + "wide" +]; + +/** Turn deferred hydration on for the spec file (the suite runs eager). */ +export function deferredHydration() { + beforeAll(() => setDeferredHydration(true)); + afterAll(() => setDeferredHydration(false)); +} + +export function bootEdges(options: { drift?: boolean } = {}) { + const { shell, rest } = loadArtifact("frame-dependency-edges"); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const container = document.createElement("div"); + document.body.appendChild(container); + installServerComponents(makeHost().host); + vi.stubGlobal("fetch", async () => { + throw new Error("unexpected fetch"); + }); + const warnings: string[] = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); + const list = createServerReference(FID); + const App = makeApp(() => list(...ARGS), options); + applyChunk(container, shell + rest, true); + mounted.length = 0; + const dispose = hydrate(() => , container); + const text = (id: string) => container.querySelector(`#${id}`)!.textContent; + /** Mounted occurrences, sorted. */ + const seen = () => [...mounted].sort(); + return { + container, + warnings, + text, + seen, + dispose: () => { + dispose(); + container.remove(); + } + }; +} diff --git a/packages/web/test/hydration/frame-dependency-edges-local.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-local.spec.tsx new file mode 100644 index 000000000..11f5cbb9e --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-local.spec.tsx @@ -0,0 +1,27 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write to an occurrence's own state (a +// named signal no record lists) mounts nothing beyond what the event woke. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { bootEdges, deferredHydration } from "./frame-dependency-edges-helpers.jsx"; + +deferredHydration(); + +describe("document face — dependency edges — deferred hydrate, own state", () => { + test("a write to an occurrence's own state mounts nothing else", () => { + const page = bootEdges(); + page.container + .querySelector("#item-b")! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + // The click mounted its occurrence; the handler wrote that occurrence's + // counter, which no other occurrence read (`late` mounts on every named + // write: the server could not see its reads). + expect(page.seen()).toEqual(["item:b", "late"]); + flush(); + expect(page.text("item-b")).toBe("b:USD:1"); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges-store.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-store.spec.tsx new file mode 100644 index 000000000..2e1b202a0 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-store.spec.tsx @@ -0,0 +1,27 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write the records cannot vouch for — +// a store (a write carries no signal name) — mounts every occurrence left before it applies, as deferred +// hydration did before edges. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { shop } from "../harness/frame-dependency-edges.jsx"; +import { ALL, bootEdges, deferredHydration } from "./frame-dependency-edges-helpers.jsx"; + +deferredHydration(); + +describe("document face — dependency edges — deferred hydrate, fallback", () => { + test("a write to a store (a write carries no signal name) mounts every occurrence left first", () => { + const page = bootEdges(); + shop.setCart(1); + expect(page.seen()).toEqual(["cart", "late"]); + shop.setStore(s => void (s.n = 1)); + expect(page.seen()).toEqual(ALL); + flush(); + expect(page.text("store")).toBe("store:1"); + expect(page.text("item-a")).toBe("a:USD:0"); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges-wide.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-wide.spec.tsx new file mode 100644 index 000000000..47c498d6a --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-wide.spec.tsx @@ -0,0 +1,27 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write the records cannot vouch for — +// a wide signal (read by a projection, whose readers are not tracked) — mounts every occurrence left before it applies, as deferred +// hydration did before edges. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { shop } from "../harness/frame-dependency-edges.jsx"; +import { ALL, bootEdges, deferredHydration } from "./frame-dependency-edges-helpers.jsx"; + +deferredHydration(); + +describe("document face — dependency edges — deferred hydrate, fallback", () => { + test("a write to a wide signal (read by a projection, whose readers are not tracked) mounts every occurrence left first", () => { + const page = bootEdges(); + shop.setCart(1); + expect(page.seen()).toEqual(["cart", "late"]); + shop.setWideSource(2); + expect(page.seen()).toEqual(ALL); + flush(); + expect(page.text("wide")).toBe("wide:2"); + expect(page.text("item-a")).toBe("a:USD:0"); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges.spec.tsx b/packages/web/test/hydration/frame-dependency-edges.spec.tsx new file mode 100644 index 000000000..54ee24d9e --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges.spec.tsx @@ -0,0 +1,36 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half, deferred hydration: a write before the idle +// pass mounts only the unmounted occurrences the server recorded as reading +// the written signal, before the write applies. Hydrates the document +// test/server/frame-dependency-edges.spec.tsx rendered. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { shop } from "../harness/frame-dependency-edges.jsx"; +import { drain } from "./frame-live-document-helpers.js"; +import { bootEdges, deferredHydration } from "./frame-dependency-edges-helpers.jsx"; + +deferredHydration(); + +describe("document face — dependency edges — deferred hydrate", () => { + test("a named write mounts only the occurrences that read it", async () => { + const page = bootEdges(); + expect(page.seen()).toEqual([]); + shop.setCart(1); + // The cart widget, and `late` (the server could not see its reads, so it + // mounts on every named write); none of the items. + expect(page.seen()).toEqual(["cart", "late"]); + flush(); + expect(page.text("cart")).toBe("cart:1"); + shop.setCurrency("EUR"); + // Every item reads the currency; the rest stays unmounted. + expect(page.seen()).toEqual(["cart", "item:a", "item:b", "item:c", "item:d", "late"]); + flush(); + expect(page.text("item-c")).toBe("c:EUR:0"); + expect(page.text("store")).toBe("store:0"); + await drain(); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/server/frame-dependency-edges.spec.tsx b/packages/web/test/server/frame-dependency-edges.spec.tsx new file mode 100644 index 000000000..11de261c6 --- /dev/null +++ b/packages/web/test/server/frame-dependency-edges.spec.tsx @@ -0,0 +1,54 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — server half. Each document frame ships which slot +// occurrences read which plain signals they did not create, as a per-frame +// aggregate (`sc:deps::`, `sid -> "item#0-3"`), plus the signals +// whose readers it could not track (`*`) and the occurrences with reads it +// could not see (`!`). Writes the artifact +// test/hydration/frame-dependency-edges.spec.tsx hydrates. +import { describe, expect, test } from "vitest"; +import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; +import { ARGS, FID, makeApp, makePageComponent } from "../harness/frame-dependency-edges.jsx"; +import { recordStream, writeArtifact } from "./artifact-recorder.js"; + +/** The frame's `sc:deps` records, in order. */ +function depsRecords(html: string, site: string): Record[] { + const key = `_$HY.r["sc:deps:${site}:`; + const out: Record[] = []; + for (let at = html.indexOf(key); at >= 0; at = html.indexOf(key, at + 1)) { + const open = html.indexOf("{", at); + out.push(JSON.parse(html.slice(open, html.indexOf("}", open) + 1))); + } + return out; +} + +describe("document face — dependency edges — server render", () => { + test("the frame ships its fills' reads of shell signals as one aggregate record", async () => { + const Inline = frameTransformDirectResult(makePageComponent(), { id: FID, args: ARGS }) as any; + const App = makeApp(() => Promise.resolve(Inline)); + const { shell, rest } = await recordStream(() => , { + plugins: [ServerComponentPlugin] + } as any); + const records = depsRecords(shell + rest, FID); + expect(records.length).toBe(1); + const deps = records[0]; + // The shell's three plain signals, named in creation order under one owner. + const named = Object.keys(deps).filter(k => k !== "*" && k !== "!"); + const owner = named[0].slice(0, named[0].lastIndexOf("~")); + const [cart, currency, wideSource] = [0, 1, 2].map(n => `${owner}~${n}`); + // `currency`: every item (one run); `cart`: the cart widget only (the + // late widget reads it through a client-source memo the server never runs). + expect(deps[currency]).toBe("item#0-3"); + expect(deps[cart]).toBe("cart#0"); + // A projection read `wideSource`: its readers are not tracked. + expect(deps["*"]).toBe(wideSource); + expect(deps[wideSource]).toBeUndefined(); + // The client-source memo's reads are the client's: `late` mounts on any named write. + expect(deps["!"]).toBe("late#0"); + // Own state (the items' click counters) and unnamed sources (the store, + // the derived signal) are not edges. + expect(named.sort()).toEqual([cart, currency].sort()); + writeArtifact("frame-dependency-edges", { name: "frame-dependency-edges", shell, rest }); + }); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index ec9ba5ed5..ed798cdf0 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -1,30 +1,30 @@ { "signals: core floor (createSignal/Memo/Effect/Root/flush)": { - "cap": "7.25 KB", - "minified": 19773 + "cap": "7.27 KB", + "minified": 19830 }, "app: render + one signal (the simple-app floor)": { - "cap": "9.56 KB", - "minified": 26664 + "cap": "9.59 KB", + "minified": 26721 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { "cap": "18.19 KB", "minified": 53236 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "33.46 KB", - "minified": 102629 + "cap": "33.99 KB", + "minified": 104802 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "37.58 KB", - "minified": 115982 + "cap": "38.23 KB", + "minified": 118162 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", "minified": 3324 }, "server: renderToString (the server-render floor)": { - "cap": "20.59 KB", - "minified": 72154 + "cap": "20.63 KB", + "minified": 72181 } } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ba04cf2e8..3e01988e9 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -593,6 +593,14 @@ module.exports = [ // 19,773 B; measured locally (linux) at 7,238 B (19,773 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 7.25 -> 7.27 KB, measured locally (linux) at 7,259 B + // brotli / 19,830 B minified against next's 7,238 / 19,773 (+21 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. limit: floorCaps["signals: core floor (createSignal/Memo/Effect/Root/flush)"], capMinified: floorMinified["signals: core floor (createSignal/Memo/Effect/Root/flush)"], alias @@ -1376,8 +1384,16 @@ module.exports = [ // 28,005 B; measured locally (linux) at 9,943 B (28,005 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - limit: "9.96 KB", - capMinified: 28005, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 9.96 -> 9.98 KB, measured locally (linux) at 9,962 B + // brotli / 28,066 B minified against next's 9,946 / 28,009 (+16 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "9.98 KB", + capMinified: 28066, alias }, { @@ -1407,8 +1423,16 @@ module.exports = [ // 21,584 B; measured locally (linux) at 7,850 B (21,584 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - limit: "7.86 KB", - capMinified: 21584 + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 7.86 -> 7.88 KB, measured locally (linux) at 7,867 B + // brotli / 21,641 B minified against next's 7,850 / 21,584 (+17 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "7.88 KB", + capMinified: 21641 }, { name: "app: render + one signal (the simple-app floor)", @@ -1656,6 +1680,14 @@ module.exports = [ // 26,664 B; measured locally (linux) at 9,549 B (26,664 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 9.56 -> 9.59 KB, measured locally (linux) at 9,573 B + // brotli / 26,721 B minified against next's 9,549 / 26,664 (+24 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. limit: floorCaps["app: render + one signal (the simple-app floor)"], capMinified: floorMinified["app: render + one signal (the simple-app floor)"], alias @@ -2583,8 +2615,16 @@ module.exports = [ // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 29.91 -> 30.09 KB, measured at 30,075 B brotli / 94,579 B minified (+165 B brotli over the cap, +646 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "30.09 KB", - capMinified: 94579, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 30.09 -> 30.13 KB, measured locally (linux) at 30,111 B + // brotli / 94,640 B minified against next's 30,089 / 94,583 (+22 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "30.13 KB", + capMinified: 94640, alias }, { @@ -2868,8 +2908,16 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 12.87 -> 13.14 KB, measured at 13,122 B brotli / 36,900 B minified (+252 B brotli over the cap, +644 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "13.14 KB", - capMinified: 36900, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 13.14 -> 13.19 KB, measured locally (linux) at 13,172 B + // brotli / 36,957 B minified against next's 13,122 / 36,900 (+50 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "13.19 KB", + capMinified: 36957, alias }, { @@ -3781,8 +3829,16 @@ module.exports = [ // 27,194 B; measured locally (linux) at 9,752 B (27,194 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - limit: "9.77 KB", - capMinified: 27194, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 9.77 -> 9.79 KB, measured locally (linux) at 9,774 B + // brotli / 27,253 B minified against next's 9,752 / 27,196 (+22 B; +57 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "9.79 KB", + capMinified: 27253, alias }, { @@ -3896,8 +3952,16 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 25.66 -> 25.95 KB, measured at 25,940 B brotli / 80,846 B minified (+280 B brotli over the cap, +640 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "25.95 KB", - capMinified: 80846, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 25.95 -> 25.97 KB, measured locally (linux) at 25,958 B + // brotli / 80,902 B minified against next's 25,940 / 80,846 (+18 B; +56 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "25.97 KB", + capMinified: 80902, alias }, { @@ -4044,8 +4108,16 @@ module.exports = [ // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 31.72 -> 31.96 KB, measured at 31,950 B brotli / 101,408 B minified (+230 B brotli over the cap, +648 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - limit: "31.96 KB", - capMinified: 101408, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 31.96 -> 31.98 KB, measured locally (linux) at 31,963 B + // brotli / 101,469 B minified against next's 31,950 / 101,408 (+13 B; +61 B + // minified). Plain signals take a name (`~`, a per-owner counter + // reset with the owner's children) while deferred hydration is on, the + // write hook receives it, and a dev-only read watch (stripped in + // production) backs the drift check. Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + limit: "31.98 KB", + capMinified: 101469, alias }, { @@ -4548,8 +4620,20 @@ module.exports = [ // in a region mounts under the owner that placed it (the regions tier's // placement), and a fill's two claim windows share one claim owner. Cap at // measured + 10 B rounded up to 0.01 KB. - limit: "12.25 KB", - capMinified: 36845, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 12.25 -> 12.79 KB, measured locally (linux) at 12,775 B + // brotli / 38,740 B minified against next's 12,235 / 36,845 (+540 B; +1895 + // B minified). Deferred hydration mounts only the slot occurrences the + // server recorded as readers of the written signal (an index built from the + // per-frame `sc:deps` records), falling back to mounting everything on an + // unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "12.79 KB", + capMinified: 38740, alias: framesAlias, external: framesExternal }, @@ -4911,6 +4995,18 @@ module.exports = [ // in a region mounts under the owner that placed it (the regions tier's // placement), and a fill's two claim windows share one claim owner. Cap // (floor-caps.json) at measured + 10 B rounded up to 0.01 KB. + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 33.46 -> 33.99 KB, measured locally (linux) at 33,980 B + // brotli / 104,802 B minified against next's 33,441 / 102,629 (+539 B; + // +2173 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal (an index built from + // the per-frame `sc:deps` records), falling back to mounting everything on + // an unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -5213,6 +5309,18 @@ module.exports = [ // in a region mounts under the owner that placed it (the regions tier's // placement), and a fill's two claim windows share one claim owner. Cap // (floor-caps.json) at measured + 10 B rounded up to 0.01 KB. + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 37.58 -> 38.23 KB, measured locally (linux) at 38,214 B + // brotli / 118,162 B minified against next's 37,565 / 115,982 (+649 B; + // +2180 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal (an index built from + // the per-frame `sc:deps` records), falling back to mounting everything on + // an unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5343,8 +5451,20 @@ module.exports = [ // in a region mounts under the owner that placed it (the regions tier's // placement), and a fill's two claim windows share one claim owner. Cap at // measured + 10 B rounded up to 0.01 KB. - limit: "35.42 KB", - capMinified: 108973, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 35.42 -> 36.03 KB, measured locally (linux) at 36,011 B + // brotli / 111,151 B minified against next's 35,408 / 108,973 (+603 B; + // +2178 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal (an index built from + // the per-frame `sc:deps` records), falling back to mounting everything on + // an unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "36.03 KB", + capMinified: 111151, alias: pageAlias }, { @@ -5437,8 +5557,20 @@ module.exports = [ // measured locally (linux) at 41,463 B (123,926 B minified). Lower only: // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. - limit: "41.48 KB", - capMinified: 123926, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 41.48 -> 42.13 KB, measured locally (linux) at 42,119 B + // brotli / 126,118 B minified against next's 41,439 / 123,929 (+680 B; + // +2189 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal (an index built from + // the per-frame `sc:deps` records), falling back to mounting everything on + // an unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.13 KB", + capMinified: 126118, alias: pageAlias }, { @@ -5608,8 +5740,20 @@ module.exports = [ // measured locally (linux) at 40,884 B (126,460 B minified). Lower only: // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. - limit: "40.90 KB", - capMinified: 126460, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 40.90 -> 41.49 KB, measured locally (linux) at 41,471 B + // brotli / 128,643 B minified against next's 40,817 / 126,463 (+654 B; + // +2180 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal (an index built from + // the per-frame `sc:deps` records), falling back to mounting everything on + // an unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "41.49 KB", + capMinified: 128643, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5748,8 +5892,20 @@ module.exports = [ // measured locally (linux) at 47,001 B (141,204 B minified). Lower only: // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. - limit: "47.02 KB", - capMinified: 141204, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 47.02 -> 47.69 KB, measured locally (linux) at 47,673 B + // brotli / 143,401 B minified against next's 47,006 / 141,205 (+667 B; + // +2196 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal (an index built from + // the per-frame `sc:deps` records), falling back to mounting everything on + // an unnamed, wide or unrecorded write; a dev-only check warns when a mount + // reads a signal its record lacks. Plain signals take a name + // (`~`, a per-owner counter reset with the owner's children) + // while deferred hydration is on, the write hook receives it, and a + // dev-only read watch (stripped in production) backs the drift check. Cap + // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "47.69 KB", + capMinified: 143401, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5870,6 +6026,14 @@ module.exports = [ // 01270641): 20.65 KB -> 20.59 KB, recorded minified 72,466 -> 72,154 B; // measured locally (linux) at 20,573 B (72,154 B minified). Lower only: cap // at measured + 10 B rounded up to 0.01 KB; recorded minified never raised. + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 20.59 -> 20.63 KB, measured locally (linux) at 20,616 B + // brotli / 72,181 B minified against next's 20,624 / 72,131 (-8 B; +50 B + // minified). Slot fills run under a read sink and the server emits one + // `sc:deps` record per frame batch (signal name -> folded occurrence runs, + // plus wide and untracked sets); plain signals report their name when read. + // Next itself measures 20,624 B here, over the recorded 20.59 KB. Cap at + // measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["server: renderToString (the server-render floor)"], capMinified: floorMinified["server: renderToString (the server-render floor)"], alias: serverAlias, From f129a9a365d99969973a354190dbf98ebdb35cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:56:22 +0000 Subject: [PATCH 3/8] perf(signals,solid): plain-signal naming is pay-for-use Non-server-component apps paid for dependency-edge naming: the signals core always carried the per-owner counter and its resets, and the server named and counted every plain signal (materializing owner ids) and allocated a read sink on every memo pull. - signals: the namer moves to core/identities.ts. Core keeps one null-checked slot (`signalHook`) that createSignal calls, and the write hook now receives the written node. Names live in a side table (`signalName`) instead of a mangled field, and a count restarts when its owner's pass generation moves on, so core has no reset sites. The dev read watch is a `DEV.hooks.onRead` hook. Signals and app scenarios measure byte-identical to next. - server: naming, memo read sinks and the projection wide sink are off until the frames server loads (`setServerSignalIdentities`). Counts move from an owner field to a WeakMap that exists only while naming is on. - attribution test: the hold-tracking-off case measured its 20 ms wait from the handler's return. A slow return under coverage on a loaded runner shortens that, so it now measures from the frame's open. - size: the signals, app and server caps go back to next's. Only the frames and page caps rise, with dated ledger notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/signals/src/core/core.ts | 50 +--- packages/signals/src/core/dev.ts | 2 + packages/signals/src/core/identities.ts | 58 +++++ packages/signals/src/core/index.ts | 4 +- packages/signals/src/core/owner.ts | 6 +- packages/signals/src/index.ts | 2 +- packages/signals/src/signals.ts | 4 +- .../tests/attribution-interactions.test.ts | 8 +- .../signals/tests/signal-identities.test.ts | 13 +- packages/solid/src/index.ts | 4 +- packages/solid/src/internal.ts | 11 +- packages/solid/src/server/hydration.ts | 9 +- packages/solid/src/server/index.ts | 11 +- packages/solid/src/server/signals.ts | 82 ++++-- packages/web/frames/src/client.ts | 9 +- packages/web/frames/src/frame-sink.ts | 8 + scripts/size/floor-caps.json | 20 +- scripts/size/scenarios.js | 236 ++++++------------ 18 files changed, 266 insertions(+), 271 deletions(-) create mode 100644 packages/signals/src/core/identities.ts diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index cc970703e..083d79572 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -366,9 +366,6 @@ export function recompute(el: Computed, create: boolean = false): void { // and own nothing to park. if (isEffect === EFFECT_TRACKED || el._config & CONFIG_STAGED) disposeChildren(el); else if (el._firstChild !== null || el._disposal !== null) parkChildren(el); - // A pass that made only plain signals still restarts their names (the - // server resets every re-run). - else if (sigCounts !== null) sigCounts.delete(el); if (__DEV__) clearSignals(el); } @@ -1142,7 +1139,6 @@ function parkChildren(el: Computed): void { el._disposal = null; el._firstChild = null; el._childCount = 0; - if (sigCounts !== null) sigCounts.delete(el); } /** @@ -1692,7 +1688,7 @@ export function pullComputed(owner: Computed, c: Computed): void { } export function read(el: Signal | Computed): T { - if (__DEV__ && readWatch !== null && (el as any)._sid !== undefined) readWatch((el as any)._sid); + if (__DEV__) DEV.hooks.onRead?.(el); if (__DEV__ && asyncTailFlights !== 0 && !tracking && untrackDepth === 0) checkPostAwaitRead( el, @@ -2040,52 +2036,14 @@ export function captureWriteSnapshot(el: Signal | Computed, current: T) * unmounted component would have observed, so the client mounts what is * left before the write applies, and clears the hook. `null` otherwise. */ -export let writeHook: ((sid?: string) => void) | null = null; +export let writeHook: ((node?: Signal | Computed) => void) | null = null; /** @internal */ -export function setWriteHook(hook: ((sid?: string) => void) | null): void { +export function setWriteHook(hook: ((node?: Signal | Computed) => void) | null): void { writeHook = hook; } -// Deferred hydration, dependency edges: plain-signal identities. -// While on (`setSignalIdentities`, the server-components client turns it on -// before hydrating), a plain signal created under an id-bearing owner is -// named `~`, n counting the plain signals created under that -// owner (transparent owners defer to the nearest id-bearing one, as child -// ids do), reset wherever the owner's child counter resets. The server names -// the same signal the same way, so an edge it shipped (a slot fill read it) -// matches the client node; the write hook receives the name. -let sigCounts: WeakMap | null = null; -/** @internal */ -export function setSignalIdentities(on: boolean): void { - sigCounts = on ? sigCounts || new WeakMap() : null; -} -/** @internal Reset an owner's plain-signal counter (with its child counter). */ -export function resetSignalCount(owner: Owner): void { - if (sigCounts !== null) sigCounts.delete(owner); -} -/** - * @internal Dev only: called with the identity of every named signal read - * while set — the frames client's identity-drift check watches what a - * deferred occurrence reads as it mounts. Never called in prod builds. - */ -export let readWatch: ((sid: string) => void) | null = null; -/** @internal */ -export function setReadWatch(watch: ((sid: string) => void) | null): void { - if (__DEV__) readWatch = watch; -} -/** @internal */ -export function identifySignal(node: Signal, owner: Owner | null): void { - if (sigCounts === null || !owner) return; - let o: Owner = owner; - while (o._config & CONFIG_TRANSPARENT && o._parent) o = o._parent; - if (o.id == null) return; - const n = sigCounts.get(o) || 0; - sigCounts.set(o, n + 1); - (node as any)._sid = o.id + "~" + n; -} - export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T)): T { - if (writeHook !== null) writeHook((el as any)._sid); + if (writeHook !== null) writeHook(el); if ( __DEV__ && !(el._config & CONFIG_OWNED_WRITE) && diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 846debf64..cae95e5c5 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -30,6 +30,8 @@ export interface DevHooks { onGraph?: (value: any, owner: Owner | null) => void; onUpdate?: () => void; onStoreNodeUpdate?: (state: any, property: PropertyKey, value: any, prev: any) => void; + /** @internal Every node read (the frames client's identity-drift check; `setReadWatch`). */ + onRead?: (node: object) => void; } /** diff --git a/packages/signals/src/core/identities.ts b/packages/signals/src/core/identities.ts new file mode 100644 index 000000000..04a98ec6d --- /dev/null +++ b/packages/signals/src/core/identities.ts @@ -0,0 +1,58 @@ +import { CONFIG_TRANSPARENT } from "./constants.js"; +import { DEV } from "./dev.js"; +import { getOwner } from "./owner.js"; +import type { Owner, Signal } from "./types.js"; + +// Deferred hydration, dependency edges: plain-signal identities. +// While on (`setSignalIdentities`; the server-components client turns it on +// before hydrating), a plain signal created under an id-bearing owner is +// named `~`, n counting the plain signals created under that +// owner in its current pass (transparent owners defer to the nearest +// id-bearing one, as child ids do). The server names the same signal the +// same way and restarts the count on every re-run, so an edge it shipped (a +// slot fill read it) matches the client node; the write hook receives the +// node and looks its name up (`signalName`). The count restarts when the owner's pass +// generation (`_depGen`, bumped by every recompute) moves on. Only the +// frames client imports this module, so nothing here ships without it. +// Names live in a side table, not on the node: frames reads them across the +// package boundary, where a mangled field would not survive. +let counts: WeakMap | null = null; +const names = new WeakMap(); + +/** @internal The identity of a plain signal named while identities were on. */ +export function signalName(node: object | undefined): string | undefined { + return node === undefined ? undefined : names.get(node); +} + +function identify(node: Signal): void { + let o = getOwner(); + if (!o) return; + while (o._config & CONFIG_TRANSPARENT && o._parent) o = o._parent; + if (o.id == null) return; + const gen = (o as any)._depGen as number | undefined; + let c = counts!.get(o); + if (c === undefined || c[0] !== gen) counts!.set(o, (c = [gen, 0])); + names.set(node, o.id + "~" + c[1]++); +} + +/** @internal Name plain signals from now on (`on`), or stop. */ +export function setSignalIdentities(on: boolean): void { + counts = on ? counts || new WeakMap() : null; + signalHook = on ? identify : null; +} + +/** + * @internal Called with every plain signal `createSignal` makes while set + * (the namer above). Never set unless `setSignalIdentities` is imported, so + * a bundle without it keeps only a null check. + */ +export let signalHook: ((node: Signal) => void) | null = null; + +/** + * @internal Dev only: `watch` is called with every node read while set (the + * dev hook `onRead`) — the frames client's identity-drift check watches the + * named signals a deferred occurrence reads as it mounts. A no-op in prod. + */ +export function setReadWatch(watch: ((node: object) => void) | null): void { + if (__DEV__) DEV.hooks.onRead = watch || undefined; +} diff --git a/packages/signals/src/core/index.ts b/packages/signals/src/core/index.ts index a33245e7b..b2060560c 100644 --- a/packages/signals/src/core/index.ts +++ b/packages/signals/src/core/index.ts @@ -10,9 +10,6 @@ export { read, setSignal, setWriteHook, - setSignalIdentities, - setReadWatch, - identifySignal, setMemo, markRefresh, staleValues, @@ -39,6 +36,7 @@ export { cleanup, peekNextChildId } from "./owner.js"; +export { setSignalIdentities, signalName, signalHook, setReadWatch } from "./identities.js"; export { createContext, getContext, diff --git a/packages/signals/src/core/owner.ts b/packages/signals/src/core/owner.ts index 74eba3a25..487354ce7 100644 --- a/packages/signals/src/core/owner.ts +++ b/packages/signals/src/core/owner.ts @@ -15,7 +15,6 @@ import { enterDisposal, exitDisposal, PRIMITIVE_IN_FORBIDDEN_SCOPE_MESSAGE, - resetSignalCount, runWithOwner, tracking } from "./core.js"; @@ -127,10 +126,7 @@ export function disposeChildren(node: Owner, self: boolean = false, zombie?: boo // would still be holding. child = child._nextSibling; } - if (!zombie) { - node._childCount = 0; - resetSignalCount(node); - } + if (!zombie) node._childCount = 0; // O(1) splice out of parent's chain on individual dispose. Skipped during // batch dispose (parent already disposed) and for a zombie (it sits on the // parent's parked frame, drained whole by the commit). We leave diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index fcebd1b1c..adcef3cb0 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -40,7 +40,7 @@ export { export { setConsoleFooter } from "./core/index.js"; /** @internal The pre-write hook (`setWriteHook` in core/core.ts); the server-components client's deferred hydration. */ /** @internal Plain-signal identities and the dev read watch (dependency edges; the server-components client). */ -export { setWriteHook, setSignalIdentities, setReadWatch } from "./core/index.js"; +export { setWriteHook, setSignalIdentities, setReadWatch, signalName } from "./core/index.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js"; /** * Observe tier (diagnostics channel, attribution hook slot + interaction diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index 01ad3feac..8dbb3da1e 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -20,7 +20,7 @@ import { runWithOwner, setMemo, setSignal, - identifySignal, + signalHook, signal, trackedEffect, untrack, @@ -417,7 +417,7 @@ export function createSignal( } const node = signal(first as any, second as SignalOptions); if (__OBSERVE__) registerGraph(node, getOwner()); - identifySignal(node, getOwner()); + if (signalHook !== null) signalHook(node); return [accessor(node), setSignal.bind(null, node as any) as Setter]; } diff --git a/packages/signals/tests/attribution-interactions.test.ts b/packages/signals/tests/attribution-interactions.test.ts index da9844ce8..50eae6656 100644 --- a/packages/signals/tests/attribution-interactions.test.ts +++ b/packages/signals/tests/attribution-interactions.test.ts @@ -504,7 +504,13 @@ describe("a handler that returns a promise", () => { await wait(20); }); await until(() => interactionLog().length === 1, "the record to settle"); - expect(interactionLog()[0].continuationMs).toBeGreaterThanOrEqual(15); + // The 20 ms timer starts inside the handler, before it returns, and + // `continuationMs` counts from the return: a slow return (coverage + // instrumentation on a loaded runner) shortens it. Measured from the + // frame's open, the record waited the whole timer. + const record = interactionLog()[0]; + expect(record.continuationMs).toBeGreaterThan(0); + expect(record.handlerMs + record.continuationMs!).toBeGreaterThanOrEqual(15); expect(findings).toHaveLength(0); }); }); diff --git a/packages/signals/tests/signal-identities.test.ts b/packages/signals/tests/signal-identities.test.ts index 2c9490174..faf17709a 100644 --- a/packages/signals/tests/signal-identities.test.ts +++ b/packages/signals/tests/signal-identities.test.ts @@ -2,10 +2,10 @@ // a plain signal created under an id-bearing owner is named `~`, n // counting the plain signals created under the nearest id-bearing owner; // the count restarts when the owner re-runs, as child ids do. The pre-write -// hook receives the name (none for a store or a derived signal), and the dev +// hook receives the named node (none for a store or a derived signal), and the dev // read watch sees it on every read. import { afterEach, describe, expect, test } from "vitest"; -import { setReadWatch, setSignalIdentities, setWriteHook } from "../src/core/index.js"; +import { setReadWatch, setSignalIdentities, setWriteHook, signalName } from "../src/core/index.js"; import { createMemo, createRoot, createSignal, createStore, flush } from "../src/index.js"; afterEach(() => { @@ -16,12 +16,12 @@ afterEach(() => { function hookedWrites() { const writes: (string | undefined)[] = []; - setWriteHook(sid => writes.push(sid)); + setWriteHook(node => writes.push(signalName(node))); return writes; } describe("plain-signal identities", () => { - test("named per id-bearing owner, in creation order; the hook receives the name", () => { + test("named per id-bearing owner, in creation order; the hook receives the named node", () => { setSignalIdentities(true); const writes = hookedWrites(); const [setA, setB, setD, setS] = createRoot( @@ -83,7 +83,10 @@ describe("plain-signal identities", () => { createRoot( () => { const [a] = createSignal(0); - setReadWatch(sid => reads.push(sid)); + setReadWatch(node => { + const sid = signalName(node); + if (sid !== undefined) reads.push(sid); + }); a(); setReadWatch(null); a(); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 3160844c3..85ebd3076 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -185,7 +185,9 @@ export function creationStamp(): number { /** @internal A callback run before every signal or store write lands (deferred hydration). */ export { setWriteHook } from "@solidjs/signals"; /** @internal Dependency edges: name plain signals so a write reports which one; dev read watch. */ -export { setSignalIdentities, setReadWatch } from "@solidjs/signals"; +export { setSignalIdentities, setReadWatch, signalName } from "@solidjs/signals"; +/** @internal Dependency edges: server-only (the frames server names plain signals). */ +export function setServerSignalIdentities(_on: boolean): void {} /** @internal Dependency edges: server-only read tracking; the client runs `fn`. */ export function trackFillReads(fn: () => T): { result: T; diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index c9219f021..7e4fe678d 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -129,11 +129,16 @@ export const creationStamp: () => number = core.creationStamp; * write lands — the server-components client's deferred hydration mounts * what it left unmounted. Server: no-op. */ -export const setWriteHook: (hook: ((sid?: string) => void) | null) => void = core.setWriteHook; +export const setWriteHook: (hook: ((node?: object) => void) | null) => void = + core.setWriteHook as any; /** Dependency edges. Client: name plain signals (`~`) from now on. Server: no-op. */ export const setSignalIdentities: (on: boolean) => void = core.setSignalIdentities; -/** Dependency edges, dev only. Client: watch the identities of named signals read. Server: no-op. */ -export const setReadWatch: (watch: ((sid: string) => void) | null) => void = core.setReadWatch; +/** Dependency edges. Server: name and track plain signals from now on. Client: no-op. */ +export const setServerSignalIdentities: (on: boolean) => void = core.setServerSignalIdentities; +/** Dependency edges, dev only. Client: watch every node read. Server: no-op. */ +export const setReadWatch: (watch: ((node: object) => void) | null) => void = core.setReadWatch; +/** Dependency edges. Client: a plain signal's identity, if it was named. Server: `undefined`. */ +export const signalName: (node: object | undefined) => string | undefined = core.signalName; /** * Dependency edges. Server: run a slot fill, returning the plain-signal * identities it read (through memos too) and whether it ran a computation diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 1465d372c..bcf1d1b50 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -14,7 +14,8 @@ import { throwerOf, ownerId, onCleanup, - inServerComponentScope + inServerComponentScope, + swapSigCount } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -519,18 +520,16 @@ function ssrLoadingBoundary( let resolveSig = 0; function resolveIn(run: () => T): T { const prevCount = (o as any)._childCount; - const prevSig = (o as any)._sigCount; (o as any).id = flattenId; (o as any)._childCount = resolveCount; - (o as any)._sigCount = resolveSig; + const prevSig = swapSigCount(o, resolveSig); try { return run(); } finally { resolveCount = (o as any)._childCount; - resolveSig = (o as any)._sigCount; + resolveSig = swapSigCount(o, prevSig); (o as any).id = contentId; (o as any)._childCount = prevCount; - (o as any)._sigCount = prevSig; } } diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 96757aeb4..3e2682a43 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -151,6 +151,7 @@ export { createErrorBoundary, createRevealOrder } from "./signals.js"; export { creationStamp, trackFillReads, + setServerSignalIdentities, runInServerComponentScope, inServerComponentScope, inLiveServerComponentScope, @@ -161,11 +162,15 @@ export { } from "./signals.js"; export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; /** @internal Client-only: the server has no writes to observe. */ -export function setWriteHook(_hook: ((sid?: string) => void) | null): void {} -/** @internal Dependency edges: client-only. */ +export function setWriteHook(_hook: ((node?: object) => void) | null): void {} +/** @internal Dependency edges: client-only (the server names through `setServerSignalIdentities`). */ export function setSignalIdentities(_on: boolean): void {} /** @internal Dependency edges: client-only. */ -export function setReadWatch(_watch: ((sid: string) => void) | null): void {} +export function setReadWatch(_watch: ((node: object) => void) | null): void {} +/** @internal Dependency edges: client-only. */ +export function signalName(_node: object | undefined): string | undefined { + return undefined; +} /** @internal */ export { ssrHandleError, ssrScope } from "./hydration.js"; // After the runtime modules above, so this import adds no edge to the module diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 7d29bf9f2..90c96dec0 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -147,8 +147,6 @@ interface SSROwner { _parent: SSROwner | null; _context: Record; _childCount: number; - /** Plain signals created under this owner (dependency-edge identities), reset with `_childCount`. */ - _sigCount: number; _firstChild: SSROwner | null; _nextSibling: SSROwner | null; _disposed: boolean; @@ -339,7 +337,6 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS owner._parent = parent; owner._context = ctx; owner._childCount = 0; - owner._sigCount = 0; owner._firstChild = null; owner._nextSibling = null; owner._disposed = false; @@ -361,7 +358,6 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS _parent: parent, _context: ctx, _childCount: 0, - _sigCount: 0, _firstChild: null, _nextSibling: null, _disposed: false, @@ -376,7 +372,6 @@ function allocateOwner(explicitId: string | undefined, transparent: boolean): SS _parent: parent, _context: ctx, _childCount: 0, - _sigCount: 0, _firstChild: null, _nextSibling: null, _disposed: false, @@ -544,7 +539,8 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { // retry reset (`self=false`) must still rewind the counter — otherwise // every boundary discovery pass drifts the ids of the eventual // successful run past the client's (#2900). - node._childCount = node._sigCount = 0; + node._childCount = 0; + if (sigCounts !== null) sigCounts.delete(node); node._scopeSlot = -1; } return; @@ -557,7 +553,8 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { child = next; } node._firstChild = null; - node._childCount = node._sigCount = 0; + node._childCount = 0; + if (sigCounts !== null) sigCounts.delete(node); // Defensive: a kept-alive owner (`self=false`) re-runs from its own, // settled id. The slot is swap-owned (`ssrScope` restores it in `finally`), // so this is never observed set here — pinned by `ownerId`'s dev check. @@ -599,7 +596,8 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { export function resetOwnerForRerun(owner: Owner): void { const node = owner as unknown as SSROwner; if (node._firstChild || node._disposal) disposeOwner(owner, false); - node._childCount = node._sigCount = 0; + node._childCount = 0; + if (sigCounts !== null) sigCounts.delete(node); node._scopeSlot = -1; } @@ -666,11 +664,10 @@ export function ssrScope(fn: () => T): () => unknown { const prevId = parent.id; const prevSlot = parent._scopeSlot; const prevCount = parent._childCount; - const prevSig = parent._sigCount; parent.id = prefix; parent._scopeSlot = slot; parent._childCount = resume ? resumeCount : 0; - parent._sigCount = resume ? resumeSig : 0; + const prevSig = swapSigCount(parent, resume ? resumeSig : 0); // The unwrap step in flight, so the one catch below can record the // resume point: a single handler frame on the NotReady unwind (see // `runWithOwner`) instead of an inner catch and an outer finally. @@ -688,14 +685,14 @@ export function ssrScope(fn: () => T): () => unknown { while (typeof v === "function") { next = v as () => unknown; count = parent._childCount; - sigCount = parent._sigCount; + sigCount = swapSigCount(parent, -1); v = next(); } } catch (err) { parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; - parent._sigCount = prevSig; + swapSigCount(parent, prevSig); if (next !== undefined && err instanceof NotReadyError) { resume = next; resumeCount = count; @@ -706,7 +703,7 @@ export function ssrScope(fn: () => T): () => unknown { parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; - parent._sigCount = prevSig; + swapSigCount(parent, prevSig); return v; }; } @@ -1247,7 +1244,30 @@ let asyncReads = 0; // lands on its sources — or the request's WIDE sink, for reads whose // readers are not tracked (a projection's compute): a write to a wide // signal mounts everything left. +// +// Off until the frames server installs it (`setServerSignalIdentities`, at its +// module load): an app that renders no server components names nothing, +// and its signal reads and memo pulls take the plain paths. type ReadSink = { s: Set | null; wide?: true; untracked?: true }; +let identities = false; +// Plain signals created under each id-bearing owner, dropped wherever the +// owner's child counter resets. `null` while identities are off. +let sigCounts: WeakMap | null = null; +/** @internal Dependency edges: name and track plain signals from now on (`on`). */ +export function setServerSignalIdentities(on: boolean): void { + identities = on; + sigCounts = on ? sigCounts || new WeakMap() : null; +} +/** + * @internal Set `owner`'s plain-signal count to `n` (a scope swap), returning + * the previous one; `n < 0` only reads it. A no-op while identities are off. + */ +export function swapSigCount(owner: unknown, n: number): number { + if (sigCounts === null) return 0; + const prev = sigCounts.get(owner as SSROwner) || 0; + if (n >= 0) sigCounts.set(owner as SSROwner, n); + return prev; +} let readSink: ReadSink | null = null; // The innermost slot fill being tracked (`trackFillReads`), for computations // whose reads the server cannot see: a client-source memo or effect, a @@ -1272,7 +1292,9 @@ function signalIdentity(owner: SSROwner | null): string | undefined { while (o._transparent && o._parent) o = o._parent; if (o.id == null) return undefined; const id = materializeId(o); - return id + "~" + o._sigCount++; + const n = sigCounts!.get(o) || 0; + sigCounts!.set(o, n + 1); + return id + "~" + n; } /** * @internal Run a slot fill, returning its result, the plain-signal @@ -1346,7 +1368,7 @@ export function createSignal( } // Plain value form — no ID allocation (IDs are only for owners/computations). // Dependency edges: an identity, reported on read (see `readSink`). - const sid = signalIdentity(currentOwner); + const sid = identities ? signalIdentity(currentOwner) : undefined; return [ sid !== undefined ? () => { @@ -1465,12 +1487,14 @@ export function createMemo( sharedConfig.context = ctx; const reads = asyncReads; const prevSink = readSink; - const sink: ReadSink = (readSink = { s: null }); + const sink: ReadSink | null = identities ? (readSink = { s: null }) : null; try { return runWithOwner(owner, () => runWithObserver(comp, () => comp.compute(comp.value))); } finally { - readSink = prevSink; - memoReads = sink.s; + if (sink !== null) { + readSink = prevSink; + memoReads = sink.s; + } sharedConfig.context = prev; if (asyncReads !== reads) comp.moving = true; } @@ -1643,7 +1667,7 @@ function createSyncMemo( resetOwnerForRerun(owner); const reads = asyncReads; const prevSink = readSink; - const sink: ReadSink = (readSink = { s: null }); + const sink: ReadSink | null = identities ? (readSink = { s: null }) : null; // One handler frame (see `runWithOwner`): the normal path restores // inline, the catch restores and re-raises. let result: T; @@ -1651,8 +1675,10 @@ function createSyncMemo( result = compute(value) as T; } catch (err) { currentOwner = prev; - readSink = prevSink; - memoReads = sink.s; + if (sink !== null) { + readSink = prevSink; + memoReads = sink.s; + } if (asyncReads !== reads) moving = true; if (err instanceof NotReadyError) { // Don't latch a value — the engine re-pulls — but keep the signal @@ -1668,8 +1694,10 @@ function createSyncMemo( throw err; } currentOwner = prev; - readSink = prevSink; - memoReads = sink.s; + if (sink !== null) { + readSink = prevSink; + memoReads = sink.s; + } if (asyncReads !== reads) moving = true; value = result; error = undefined; @@ -2857,7 +2885,7 @@ export function createProjection( const ctx = sharedConfig.context; // Dependency edges: a projection's readers are not tracked (store // reads report nothing), so the signals its compute reads are WIDE. - if (ctx) { + if (ctx && identities) { const compute = fn; fn = ((...args: [T]) => wideReads(ctx, () => compute(...args))) as typeof fn; } @@ -3458,7 +3486,8 @@ export function mapArray( if (origId !== undefined) { rowOwner.id = formatChildId(origId, i); } - rowOwner._childCount = rowOwner._sigCount = 0; + rowOwner._childCount = 0; + if (sigCounts !== null) sigCounts.delete(rowOwner); s.push( options.keyed === false ? indexes @@ -3555,7 +3584,8 @@ export function repeat( if (origId !== undefined) { rowOwner.id = formatChildId(origId, i); } - rowOwner._childCount = rowOwner._sigCount = 0; + rowOwner._childCount = 0; + if (sigCounts !== null) sigCounts.delete(rowOwner); out[i] = mapFn(i + offset); } } finally { diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index ecdb180ef..6c1e7d690 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -68,6 +68,7 @@ import { setReadWatch, setSignalIdentities, setWriteHook, + signalName, sharedConfig } from "solid-js/internal"; @@ -1685,8 +1686,9 @@ function edgesCover(idx: EdgeIndex): boolean { } return true; } -function mountForWrite(sid?: string) { +function mountForWrite(node?: object) { if (mounting || (sharedConfig as any).hydrating) return; + const sid = signalName(node); if (sid === undefined || document.readyState === "loading") return mountAllDeferred(); const idx = (edgeIndex ||= buildEdgeIndex()); if (!idx || idx.wide.has(sid) || !edgesCover(idx)) return mountAllDeferred(); @@ -1723,7 +1725,10 @@ let driftReads: Map> | undefined; const driftWarned = new Set(); function watchDeferredMount(site: string, occ: string, mount: () => void) { const reads = new Set(); - setReadWatch(sid => reads.add(sid)); + setReadWatch(node => { + const sid = signalName(node); + if (sid !== undefined) reads.add(sid); + }); try { mount(); } finally { diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 7c6605ce3..0d3463dfe 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -41,9 +41,17 @@ import { ssrSanitizeError, creationStamp, shareAsyncIterable, + setServerSignalIdentities, trackFillReads } from "solid-js/internal"; +// Dependency edges: a server that renders server components names its plain +// signals (`~`, as the client's signals core does) so slot fills +// can record which ones they read. Installed at load, ahead of any request; +// an app that never loads the frames server names nothing. A no-op in the +// client build (a browser test may load this module beside the client). +setServerSignalIdentities(true); + // EXPERIMENTAL — the frames/server-components surface ships as an // experimental preview, excluded from the 2.0 stability guarantee: API // shapes and the wire format may change between prereleases (RFC 11). diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index ed798cdf0..9032e17de 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -1,30 +1,30 @@ { "signals: core floor (createSignal/Memo/Effect/Root/flush)": { - "cap": "7.27 KB", - "minified": 19830 + "cap": "7.25 KB", + "minified": 19773 }, "app: render + one signal (the simple-app floor)": { - "cap": "9.59 KB", - "minified": 26721 + "cap": "9.56 KB", + "minified": 26664 }, "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": { "cap": "18.19 KB", "minified": 53236 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "33.99 KB", - "minified": 104802 + "cap": "34.03 KB", + "minified": 104884 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "38.23 KB", - "minified": 118162 + "cap": "38.29 KB", + "minified": 118244 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", "minified": 3324 }, "server: renderToString (the server-render floor)": { - "cap": "20.63 KB", - "minified": 72181 + "cap": "20.59 KB", + "minified": 72154 } } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 3e01988e9..0b66e857c 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -593,14 +593,6 @@ module.exports = [ // 19,773 B; measured locally (linux) at 7,238 B (19,773 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 7.25 -> 7.27 KB, measured locally (linux) at 7,259 B - // brotli / 19,830 B minified against next's 7,238 / 19,773 (+21 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. limit: floorCaps["signals: core floor (createSignal/Memo/Effect/Root/flush)"], capMinified: floorMinified["signals: core floor (createSignal/Memo/Effect/Root/flush)"], alias @@ -1384,16 +1376,8 @@ module.exports = [ // 28,005 B; measured locally (linux) at 9,943 B (28,005 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 9.96 -> 9.98 KB, measured locally (linux) at 9,962 B - // brotli / 28,066 B minified against next's 9,946 / 28,009 (+16 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "9.98 KB", - capMinified: 28066, + limit: "9.96 KB", + capMinified: 28005, alias }, { @@ -1423,16 +1407,8 @@ module.exports = [ // 21,584 B; measured locally (linux) at 7,850 B (21,584 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 7.86 -> 7.88 KB, measured locally (linux) at 7,867 B - // brotli / 21,641 B minified against next's 7,850 / 21,584 (+17 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "7.88 KB", - capMinified: 21641 + limit: "7.86 KB", + capMinified: 21584 }, { name: "app: render + one signal (the simple-app floor)", @@ -1680,14 +1656,6 @@ module.exports = [ // 26,664 B; measured locally (linux) at 9,549 B (26,664 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 9.56 -> 9.59 KB, measured locally (linux) at 9,573 B - // brotli / 26,721 B minified against next's 9,549 / 26,664 (+24 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. limit: floorCaps["app: render + one signal (the simple-app floor)"], capMinified: floorMinified["app: render + one signal (the simple-app floor)"], alias @@ -2615,16 +2583,8 @@ module.exports = [ // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 29.91 -> 30.09 KB, measured at 30,075 B brotli / 94,579 B minified (+165 B brotli over the cap, +646 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 30.09 -> 30.13 KB, measured locally (linux) at 30,111 B - // brotli / 94,640 B minified against next's 30,089 / 94,583 (+22 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "30.13 KB", - capMinified: 94640, + limit: "30.09 KB", + capMinified: 94579, alias }, { @@ -2908,16 +2868,8 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 12.87 -> 13.14 KB, measured at 13,122 B brotli / 36,900 B minified (+252 B brotli over the cap, +644 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 13.14 -> 13.19 KB, measured locally (linux) at 13,172 B - // brotli / 36,957 B minified against next's 13,122 / 36,900 (+50 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "13.19 KB", - capMinified: 36957, + limit: "13.14 KB", + capMinified: 36900, alias }, { @@ -3829,16 +3781,8 @@ module.exports = [ // 27,194 B; measured locally (linux) at 9,752 B (27,194 B minified). Lower // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 9.77 -> 9.79 KB, measured locally (linux) at 9,774 B - // brotli / 27,253 B minified against next's 9,752 / 27,196 (+22 B; +57 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "9.79 KB", - capMinified: 27253, + limit: "9.77 KB", + capMinified: 27194, alias }, { @@ -3952,16 +3896,8 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 25.66 -> 25.95 KB, measured at 25,940 B brotli / 80,846 B minified (+280 B brotli over the cap, +640 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 25.95 -> 25.97 KB, measured locally (linux) at 25,958 B - // brotli / 80,902 B minified against next's 25,940 / 80,846 (+18 B; +56 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "25.97 KB", - capMinified: 80902, + limit: "25.95 KB", + capMinified: 80846, alias }, { @@ -4108,16 +4044,8 @@ module.exports = [ // Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 31.72 -> 31.96 KB, measured at 31,950 B brotli / 101,408 B minified (+230 B brotli over the cap, +648 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 31.96 -> 31.98 KB, measured locally (linux) at 31,963 B - // brotli / 101,469 B minified against next's 31,950 / 101,408 (+13 B; +61 B - // minified). Plain signals take a name (`~`, a per-owner counter - // reset with the owner's children) while deferred hydration is on, the - // write hook receives it, and a dev-only read watch (stripped in - // production) backs the drift check. Cap at measured + 10 B rounded up to - // 0.01 KB; recorded minified = measured. - limit: "31.98 KB", - capMinified: 101469, + limit: "31.96 KB", + capMinified: 101408, alias }, { @@ -4621,19 +4549,19 @@ module.exports = [ // placement), and a fill's two claim windows share one claim owner. Cap at // measured + 10 B rounded up to 0.01 KB. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 12.25 -> 12.79 KB, measured locally (linux) at 12,775 B - // brotli / 38,740 B minified against next's 12,235 / 36,845 (+540 B; +1895 + // next @ fb498491): 12.25 -> 12.79 KB, measured locally (linux) at 12,778 B + // brotli / 38,769 B minified against next's 12,235 / 36,845 (+543 B; +1924 // B minified). Deferred hydration mounts only the slot occurrences the // server recorded as readers of the written signal (an index built from the // per-frame `sc:deps` records), falling back to mounting everything on an // unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. limit: "12.79 KB", - capMinified: 38740, + capMinified: 38769, alias: framesAlias, external: framesExternal }, @@ -4996,17 +4924,17 @@ module.exports = [ // placement), and a fill's two claim windows share one claim owner. Cap // (floor-caps.json) at measured + 10 B rounded up to 0.01 KB. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 33.46 -> 33.99 KB, measured locally (linux) at 33,980 B - // brotli / 104,802 B minified against next's 33,441 / 102,629 (+539 B; - // +2173 B minified). Deferred hydration mounts only the slot occurrences + // next @ fb498491): 33.46 -> 34.03 KB, measured locally (linux) at 34,013 B + // brotli / 104,884 B minified against next's 33,441 / 102,629 (+572 B; + // +2255 B minified). Deferred hydration mounts only the slot occurrences // the server recorded as readers of the written signal (an index built from // the per-frame `sc:deps` records), falling back to mounting everything on // an unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -5310,17 +5238,17 @@ module.exports = [ // placement), and a fill's two claim windows share one claim owner. Cap // (floor-caps.json) at measured + 10 B rounded up to 0.01 KB. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 37.58 -> 38.23 KB, measured locally (linux) at 38,214 B - // brotli / 118,162 B minified against next's 37,565 / 115,982 (+649 B; - // +2180 B minified). Deferred hydration mounts only the slot occurrences + // next @ fb498491): 37.58 -> 38.29 KB, measured locally (linux) at 38,275 B + // brotli / 118,244 B minified against next's 37,565 / 115,982 (+710 B; + // +2262 B minified). Deferred hydration mounts only the slot occurrences // the server recorded as readers of the written signal (an index built from // the per-frame `sc:deps` records), falling back to mounting everything on // an unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5452,19 +5380,19 @@ module.exports = [ // placement), and a fill's two claim windows share one claim owner. Cap at // measured + 10 B rounded up to 0.01 KB. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 35.42 -> 36.03 KB, measured locally (linux) at 36,011 B - // brotli / 111,151 B minified against next's 35,408 / 108,973 (+603 B; - // +2178 B minified). Deferred hydration mounts only the slot occurrences + // next @ fb498491): 35.42 -> 36.07 KB, measured locally (linux) at 36,053 B + // brotli / 111,228 B minified against next's 35,408 / 108,973 (+645 B; + // +2255 B minified). Deferred hydration mounts only the slot occurrences // the server recorded as readers of the written signal (an index built from // the per-frame `sc:deps` records), falling back to mounting everything on // an unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "36.03 KB", - capMinified: 111151, + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. + limit: "36.07 KB", + capMinified: 111228, alias: pageAlias }, { @@ -5558,19 +5486,19 @@ module.exports = [ // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 41.48 -> 42.13 KB, measured locally (linux) at 42,119 B - // brotli / 126,118 B minified against next's 41,439 / 123,929 (+680 B; - // +2189 B minified). Deferred hydration mounts only the slot occurrences + // next @ fb498491): 41.48 -> 42.05 KB, measured locally (linux) at 42,037 B + // brotli / 126,218 B minified against next's 41,439 / 123,929 (+598 B; + // +2289 B minified). Deferred hydration mounts only the slot occurrences // the server recorded as readers of the written signal (an index built from // the per-frame `sc:deps` records), falling back to mounting everything on // an unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "42.13 KB", - capMinified: 126118, + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. + limit: "42.05 KB", + capMinified: 126218, alias: pageAlias }, { @@ -5741,19 +5669,19 @@ module.exports = [ // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 40.90 -> 41.49 KB, measured locally (linux) at 41,471 B - // brotli / 128,643 B minified against next's 40,817 / 126,463 (+654 B; - // +2180 B minified). Deferred hydration mounts only the slot occurrences + // next @ fb498491): 40.90 -> 41.57 KB, measured locally (linux) at 41,559 B + // brotli / 128,715 B minified against next's 40,817 / 126,463 (+742 B; + // +2252 B minified). Deferred hydration mounts only the slot occurrences // the server recorded as readers of the written signal (an index built from // the per-frame `sc:deps` records), falling back to mounting everything on // an unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "41.49 KB", - capMinified: 128643, + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. + limit: "41.57 KB", + capMinified: 128715, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5893,19 +5821,19 @@ module.exports = [ // cap at measured + 10 B rounded up to 0.01 KB; recorded minified never // raised. // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 47.02 -> 47.69 KB, measured locally (linux) at 47,673 B - // brotli / 143,401 B minified against next's 47,006 / 141,205 (+667 B; - // +2196 B minified). Deferred hydration mounts only the slot occurrences + // next @ fb498491): 47.02 -> 47.76 KB, measured locally (linux) at 47,741 B + // brotli / 143,506 B minified against next's 47,006 / 141,205 (+735 B; + // +2301 B minified). Deferred hydration mounts only the slot occurrences // the server recorded as readers of the written signal (an index built from // the per-frame `sc:deps` records), falling back to mounting everything on // an unnamed, wide or unrecorded write; a dev-only check warns when a mount - // reads a signal its record lacks. Plain signals take a name - // (`~`, a per-owner counter reset with the owner's children) - // while deferred hydration is on, the write hook receives it, and a - // dev-only read watch (stripped in production) backs the drift check. Cap - // at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "47.69 KB", - capMinified: 143401, + // reads a signal its record lacks. The plain-signal namer (`~`, restarted on each owner pass) ships with the frames client, + // which installs it into a null-checked slot in createSignal; signals and + // app scenarios are unchanged. Cap at measured + 10 B rounded up to 0.01 + // KB; recorded minified = measured. + limit: "47.76 KB", + capMinified: 143506, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -6026,14 +5954,6 @@ module.exports = [ // 01270641): 20.65 KB -> 20.59 KB, recorded minified 72,466 -> 72,154 B; // measured locally (linux) at 20,573 B (72,154 B minified). Lower only: cap // at measured + 10 B rounded up to 0.01 KB; recorded minified never raised. - // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over - // next @ fb498491): 20.59 -> 20.63 KB, measured locally (linux) at 20,616 B - // brotli / 72,181 B minified against next's 20,624 / 72,131 (-8 B; +50 B - // minified). Slot fills run under a read sink and the server emits one - // `sc:deps` record per frame batch (signal name -> folded occurrence runs, - // plus wide and untracked sets); plain signals report their name when read. - // Next itself measures 20,624 B here, over the recorded 20.59 KB. Cap at - // measured + 10 B rounded up to 0.01 KB; recorded minified = measured. limit: floorCaps["server: renderToString (the server-render floor)"], capMinified: floorMinified["server: renderToString (the server-render floor)"], alias: serverAlias, From 3dfecb5bd8baf4bd9fa296103f6dbab0b54a9ab8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:55:06 +0000 Subject: [PATCH 4/8] perf(solid): server dependency edges per render, off the render paths The server's naming, memo read tracking and projection wide sink move into server/edges.ts. Only the frames server imports it. The render paths keep one null-checked slot (`edgeHooks`) and capture nothing new. Two places had reset sites: - disposeOwner has none now: an owner that names a signal registers a cleanup that drops its count. - memos and id scopes key their edge state by their owner and their scope function, so their closures stay the same size. The hooks act only inside a render that asks for edges. renderToStream sets `context.edges` when the server-components codec is among its plugins, and every other render takes the plain paths. Deps records are emitted only from such a render; without them the client mounts everything on a write, as before. server: renderToString is byte-identical to next (72,131 B minified). Under CodSpeed's runner and V8 flags with callgrind, search-results 50 items is 3,024,959 instructions against next's 3,023,848, and the hn story page with no link handler is 71,027,668 against 70,849,188. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/solid/src/server/edges.ts | 158 +++++++++++++ packages/solid/src/server/hydration.ts | 8 +- packages/solid/src/server/index.ts | 4 +- packages/solid/src/server/signals.ts | 210 ++++++------------ .../test/server/signal-identities.spec.ts | 94 ++++++++ packages/web/frames/src/frame-sink.ts | 17 +- packages/web/src/server.ts | 5 + 7 files changed, 338 insertions(+), 158 deletions(-) create mode 100644 packages/solid/src/server/edges.ts create mode 100644 packages/solid/test/server/signal-identities.spec.ts diff --git a/packages/solid/src/server/edges.ts b/packages/solid/src/server/edges.ts new file mode 100644 index 000000000..173be7e47 --- /dev/null +++ b/packages/solid/src/server/edges.ts @@ -0,0 +1,158 @@ +// Deferred hydration, dependency edges: plain-signal identities and read +// tracking on the server. Only the frames server loads this (it installs the +// hooks at its module load, `setServerSignalIdentities`); the render paths in +// signals.ts hold one null-checked slot, and the hooks act only inside a +// render that asked for edges (`on`), so other renders take the plain paths. +// +// A plain signal created under an id-bearing owner is named `~` +// (n counts plain signals under that owner since its child counter last +// reset), as the client's signals core names it. A read reports the name to +// the current sink: a slot fill's (`trackFillReads`), a memo's — a memo read +// re-reports what its compute read, so an edge through a memo lands on its +// sources — or the request's WIDE sink, for reads whose readers are not +// tracked (a projection's compute): a write to a wide signal mounts +// everything left. +import { getOwner, materializeId, setEdgeHooks, type EdgeHooks } from "./signals.js"; +import { sharedConfig } from "./shared.js"; + +// Per render: only a render whose context has `edges` set (renderToStream, +// with the server-components codec among its plugins) names or tracks. +function on(): boolean { + const ctx = sharedConfig.context as any; + return !!ctx && ctx.edges === true; +} + +type ReadSink = { s: Set | null; wide?: true; untracked?: true }; +let readSink: ReadSink | null = null; +// The innermost slot fill being tracked (`trackFillReads`), for computations +// whose reads the server cannot see: a client-source memo or effect, a +// tracked effect (its compute never runs here). The fill's edges are then +// incomplete, and it mounts on every named write. +let fillSink: ReadSink | null = null; + +// Plain signals created under each id-bearing owner. An owner's entry is +// dropped with its child counter: by a cleanup on the owner (dispose, retry +// reset, rerun) or by `reset` (a reused row owner). +const counts = new WeakMap(); +function counted(owner: any, n: number): void { + if (!counts.has(owner)) { + const drop = () => void counts.delete(owner); + const d = owner._disposal; + owner._disposal = !d ? drop : Array.isArray(d) ? (d.push(drop), d) : [d, drop]; + } + counts.set(owner, n); +} + +// What each memo's last compute read (keyed by its owner), re-reported when +// the memo is read: an edge through a memo lands on its sources. +const memoReads = new WeakMap>(); +// Each id scope's resume point (`ssrScope`'s retry pull, the boundary +// resolve scope): the plain-signal count a resumed pass continues from. +const scopes = new WeakMap(); + +function reportReads(reads: Set): void { + if (readSink === null) return; + const s = (readSink.s ||= new Set()); + for (const sid of reads) s.add(sid); +} + +const hooks: EdgeHooks = { + signal(read: () => T): () => T { + let o: any = getOwner(); + if (!o || !on()) return read; + while (o._transparent && o._parent) o = o._parent; + if (o.id == null) return read; + const n = counts.get(o) || 0; + counted(o, n + 1); + const sid = materializeId(o) + "~" + n; + return () => { + if (readSink !== null) (readSink.s ||= new Set()).add(sid); + return read(); + }; + }, + run(owner: object, fn: () => T): T { + if (!on()) return fn(); + const prev = readSink; + const sink: ReadSink = (readSink = { s: null }); + try { + return fn(); + } finally { + readSink = prev; + if (sink.s !== null) memoReads.set(owner, sink.s); + else memoReads.delete(owner); + } + }, + report(owner: object) { + if (readSink === null) return; + const reads = memoReads.get(owner); + if (reads !== undefined) reportReads(reads); + }, + projection(ctx: any, fn: F): F { + if (!on()) return fn; + return ((...args: unknown[]) => { + const prev = readSink; + readSink = ctx.edgeWide ||= { s: null, wide: true }; + try { + return fn(...args); + } finally { + readSink = prev; + } + }) as unknown as F; + }, + untracked() { + if (fillSink !== null) fillSink.untracked = true; + }, + enter(key: object, owner: object, resumed: boolean): number { + if (!on()) return 0; + const prev = counts.get(owner) || 0; + counted(owner, resumed ? scopes.get(key)?.resume || 0 : 0); + return prev; + }, + mark(key: object, owner: object) { + if (!on()) return; + let st = scopes.get(key); + if (st === undefined) scopes.set(key, (st = { mark: 0, resume: 0 })); + st.mark = counts.get(owner) || 0; + }, + leave(key: object, owner: object, prev: number, keep: 0 | 1 | 2) { + if (!on()) return; + if (keep !== 0) { + let st = scopes.get(key); + if (st === undefined) scopes.set(key, (st = { mark: 0, resume: 0 })); + st.resume = keep === 1 ? st.mark : counts.get(owner) || 0; + } + counted(owner, prev); + }, + reset(owner: object) { + counts.delete(owner); + } +}; + +/** @internal Dependency edges: name and track plain signals from now on (`on`). */ +export function setServerSignalIdentities(on: boolean): void { + setEdgeHooks(on ? hooks : null); +} + +/** + * @internal Run a slot fill, returning its result, the plain-signal + * identities it read, and whether it ran a computation whose reads the + * server cannot see (`untracked`). + */ +export function trackFillReads(fn: () => T): { + result: T; + reads: Set | null; + untracked: boolean; +} { + if (!on()) return { result: fn(), reads: null, untracked: false }; + const prev = readSink; + const prevFill = fillSink; + const sink: ReadSink = { s: null }; + readSink = fillSink = sink; + try { + const result = fn(); + return { result, reads: sink.s, untracked: !!sink.untracked }; + } finally { + readSink = prev; + fillSink = prevFill; + } +} diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index bcf1d1b50..f605daa11 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -15,7 +15,7 @@ import { ownerId, onCleanup, inServerComponentScope, - swapSigCount + edgeHooks } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -517,17 +517,17 @@ function ssrLoadingBoundary( // hydrated dead (#3414). Retry passes resume the surviving holes in the // same scope, the counter continuing where the last pass left it. let resolveCount = 0; - let resolveSig = 0; function resolveIn(run: () => T): T { const prevCount = (o as any)._childCount; (o as any).id = flattenId; (o as any)._childCount = resolveCount; - const prevSig = swapSigCount(o, resolveSig); + // Dependency edges: the scope's plain-signal count continues too. + const prevSig = edgeHooks !== null ? edgeHooks.enter(resolveIn, o, true) : 0; try { return run(); } finally { resolveCount = (o as any)._childCount; - resolveSig = swapSigCount(o, prevSig); + if (edgeHooks !== null) edgeHooks.leave(resolveIn, o, prevSig, 2); (o as any).id = contentId; (o as any)._childCount = prevCount; } diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 3e2682a43..7febfc692 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -150,8 +150,6 @@ export { createErrorBoundary, createRevealOrder } from "./signals.js"; /** @internal */ export { creationStamp, - trackFillReads, - setServerSignalIdentities, runInServerComponentScope, inServerComponentScope, inLiveServerComponentScope, @@ -161,6 +159,8 @@ export { reportServerError } from "./signals.js"; export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; +/** @internal Dependency edges (the frames server installs them). */ +export { trackFillReads, setServerSignalIdentities } from "./edges.js"; /** @internal Client-only: the server has no writes to observe. */ export function setWriteHook(_hook: ((node?: object) => void) | null): void {} /** @internal Dependency edges: client-only (the server names through `setServerSignalIdentities`). */ diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 90c96dec0..6a06cac5e 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -207,7 +207,8 @@ function formatChildId(prefix: string, id: number): string { * never pays for the string. Idempotent; the string is byte-identical * whenever it is built. */ -function materializeId(owner: SSROwner): string | undefined { +/** @internal */ +export function materializeId(owner: SSROwner): string | undefined { if (owner._scopeSlot >= 0) { owner.id = formatChildId(owner.id!, owner._scopeSlot); owner._scopeSlot = -1; @@ -540,7 +541,6 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { // every boundary discovery pass drifts the ids of the eventual // successful run past the client's (#2900). node._childCount = 0; - if (sigCounts !== null) sigCounts.delete(node); node._scopeSlot = -1; } return; @@ -554,7 +554,6 @@ export function disposeOwner(owner: Owner, self: boolean = true): void { } node._firstChild = null; node._childCount = 0; - if (sigCounts !== null) sigCounts.delete(node); // Defensive: a kept-alive owner (`self=false`) re-runs from its own, // settled id. The slot is swap-owned (`ssrScope` restores it in `finally`), // so this is never observed set here — pinned by `ownerId`'s dev check. @@ -597,7 +596,6 @@ export function resetOwnerForRerun(owner: Owner): void { const node = owner as unknown as SSROwner; if (node._firstChild || node._disposal) disposeOwner(owner, false); node._childCount = 0; - if (sigCounts !== null) sigCounts.delete(node); node._scopeSlot = -1; } @@ -659,7 +657,6 @@ export function ssrScope(fn: () => T): () => unknown { // inner effect. let resume: (() => unknown) | undefined; let resumeCount = 0; - let resumeSig = 0; return () => { const prevId = parent.id; const prevSlot = parent._scopeSlot; @@ -667,13 +664,12 @@ export function ssrScope(fn: () => T): () => unknown { parent.id = prefix; parent._scopeSlot = slot; parent._childCount = resume ? resumeCount : 0; - const prevSig = swapSigCount(parent, resume ? resumeSig : 0); + const prevSig = edgeHooks !== null ? edgeHooks.enter(fn, parent, resume !== undefined) : 0; // The unwrap step in flight, so the one catch below can record the // resume point: a single handler frame on the NotReady unwind (see // `runWithOwner`) instead of an inner catch and an outer finally. let next: (() => unknown) | undefined; let count = 0; - let sigCount = 0; let v: unknown; try { v = resume || fn(); @@ -685,25 +681,25 @@ export function ssrScope(fn: () => T): () => unknown { while (typeof v === "function") { next = v as () => unknown; count = parent._childCount; - sigCount = swapSigCount(parent, -1); + if (edgeHooks !== null) edgeHooks.mark(fn, parent); v = next(); } } catch (err) { parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; - swapSigCount(parent, prevSig); - if (next !== undefined && err instanceof NotReadyError) { + const kept = next !== undefined && err instanceof NotReadyError; + if (edgeHooks !== null) edgeHooks.leave(fn, parent, prevSig, kept ? 1 : 0); + if (kept) { resume = next; resumeCount = count; - resumeSig = sigCount; } throw err; } parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; - swapSigCount(parent, prevSig); + if (edgeHooks !== null) edgeHooks.leave(fn, parent, prevSig, 0); return v; }; } @@ -1236,98 +1232,39 @@ let asyncReads = 0; // --------------------------------------------------------------------------- // Deferred hydration, dependency edges: plain-signal identities and read -// tracking. A plain signal created under an id-bearing owner is named -// `~` (n counts plain signals under that owner, reset with its -// child counter), as the client's signals core names it. A read reports the -// name to the current sink: a slot fill's (`trackFillReads`), a memo's — a -// memo read re-reports what its compute read, so an edge through a memo -// lands on its sources — or the request's WIDE sink, for reads whose -// readers are not tracked (a projection's compute): a write to a wide -// signal mounts everything left. -// -// Off until the frames server installs it (`setServerSignalIdentities`, at its -// module load): an app that renders no server components names nothing, -// and its signal reads and memo pulls take the plain paths. -type ReadSink = { s: Set | null; wide?: true; untracked?: true }; -let identities = false; -// Plain signals created under each id-bearing owner, dropped wherever the -// owner's child counter resets. `null` while identities are off. -let sigCounts: WeakMap | null = null; -/** @internal Dependency edges: name and track plain signals from now on (`on`). */ -export function setServerSignalIdentities(on: boolean): void { - identities = on; - sigCounts = on ? sigCounts || new WeakMap() : null; -} -/** - * @internal Set `owner`'s plain-signal count to `n` (a scope swap), returning - * the previous one; `n < 0` only reads it. A no-op while identities are off. - */ -export function swapSigCount(owner: unknown, n: number): number { - if (sigCounts === null) return 0; - const prev = sigCounts.get(owner as SSROwner) || 0; - if (n >= 0) sigCounts.set(owner as SSROwner, n); - return prev; -} -let readSink: ReadSink | null = null; -// The innermost slot fill being tracked (`trackFillReads`), for computations -// whose reads the server cannot see: a client-source memo or effect, a -// tracked effect (its compute never runs here). The fill's edges are then -// incomplete, and it mounts on every named write. -let fillSink: ReadSink | null = null; -function markFillUntracked(): void { - if (fillSink !== null) fillSink.untracked = true; -} -function reportRead(sid: string): void { - const sink = readSink!; - (sink.s ||= new Set()).add(sid); -} -function reportReads(reads: Set | null): void { - if (reads === null || readSink === null) return; - const s = (readSink.s ||= new Set()); - for (const sid of reads) s.add(sid); -} -function signalIdentity(owner: SSROwner | null): string | undefined { - if (!owner) return undefined; - let o: SSROwner = owner; - while (o._transparent && o._parent) o = o._parent; - if (o.id == null) return undefined; - const id = materializeId(o); - const n = sigCounts!.get(o) || 0; - sigCounts!.set(o, n + 1); - return id + "~" + n; -} -/** - * @internal Run a slot fill, returning its result, the plain-signal - * identities it read, and whether it ran a computation whose reads the - * server cannot see (`untracked`). - */ -export function trackFillReads(fn: () => T): { - result: T; - reads: Set | null; - untracked: boolean; -} { - const prev = readSink; - const prevFill = fillSink; - const sink: ReadSink = { s: null }; - readSink = fillSink = sink; - try { - const result = fn(); - return { result, reads: sink.s, untracked: !!sink.untracked }; - } finally { - readSink = prev; - fillSink = prevFill; - } +// tracking live in edges.ts, which only the frames server loads. These +// paths hold one null-checked slot, so a render without server components +// carries none of it. +/** @internal The dependency-edge hooks (edges.ts). */ +export interface EdgeHooks { + /** A plain signal created under the current owner: its named read. */ + signal(read: () => T): () => T; + /** Run a memo's compute (keyed by its owner), keeping what it read. */ + run(owner: object, fn: () => T): T; + /** A memo was read: report what its last compute read. */ + report(owner: object): void; + /** A projection's compute, its reads reported as wide. */ + projection(ctx: object, fn: F): F; + /** The current slot fill ran a computation whose reads the server cannot see. */ + untracked(): void; + /** + * An id scope (`key`) swaps in over `owner`: its plain-signal count starts + * at 0, or where the scope's last kept pass left it (`resumed`). Returns + * the count to restore. + */ + enter(key: object, owner: object, resumed: boolean): number; + /** The scope's resume point: `owner`'s count now. */ + mark(key: object, owner: object): void; + /** The scope swaps out: keep its mark (1) or its count (2) for a resumed pass; restore `prev`. */ + leave(key: object, owner: object, prev: number, keep: 0 | 1 | 2): void; + /** `owner`'s child counter was reset without disposing it (a reused row owner). */ + reset(owner: object): void; } -/** Run `fn` with its reads reported to the request's wide sink (`context.edgeWide`). */ -function wideReads(ctx: any, fn: () => T): T { - if (!ctx) return fn(); - const prev = readSink; - readSink = ctx.edgeWide ||= { s: null, wide: true }; - try { - return fn(); - } finally { - readSink = prev; - } +/** @internal */ +export let edgeHooks: EdgeHooks | null = null; +/** @internal */ +export function setEdgeHooks(hooks: EdgeHooks | null): void { + edgeHooks = hooks; } export function createSignal(): Signal; @@ -1367,15 +1304,9 @@ export function createSignal( ]; } // Plain value form — no ID allocation (IDs are only for owners/computations). - // Dependency edges: an identity, reported on read (see `readSink`). - const sid = identities ? signalIdentity(currentOwner) : undefined; + const read = () => first as T; return [ - sid !== undefined - ? () => { - if (readSink !== null) reportRead(sid); - return first as T; - } - : () => first as T, + edgeHooks !== null ? edgeHooks.signal(read) : read, v => { warnServerWrite("signal"); return ((first as any) = typeof v === "function" ? (v as (prev: T) => T)(first as T) : v); @@ -1479,22 +1410,20 @@ export function createMemo( // after another request may have set its own, and everything the pass // creates — a sync memo it pulls, a slot's content — would take that // render's ids (#3972). - // Dependency edges: what the last compute read, re-reported on read. - let memoReads: Set | null = null; const run = () => { resetOwnerForRerun(owner); const prev = sharedConfig.context; sharedConfig.context = ctx; const reads = asyncReads; - const prevSink = readSink; - const sink: ReadSink | null = identities ? (readSink = { s: null }) : null; try { - return runWithOwner(owner, () => runWithObserver(comp, () => comp.compute(comp.value))); + return runWithOwner(owner, () => + runWithObserver(comp, () => + edgeHooks !== null + ? edgeHooks.run(owner, () => comp.compute(comp.value)) + : comp.compute(comp.value) + ) + ); } finally { - if (sink !== null) { - readSink = prevSink; - memoReads = sink.s; - } sharedConfig.context = prev; if (asyncReads !== reads) comp.moving = true; } @@ -1555,7 +1484,7 @@ export function createMemo( const ssrSource = options?.ssrSource; if (ssrSource === "client") { // Skip computation. Owner created for ID parity. - markFillUntracked(); + if (edgeHooks !== null) edgeHooks.untracked(); comp.computed = true; if (loadingState) { // Client-source with commit #0: markup flushes the loading value; the @@ -1585,7 +1514,8 @@ export function createMemo( update(); } if (comp.moving) asyncReads++; - if (memoReads !== null) reportReads(memoReads); + // Dependency edges: a read re-reports what the last compute read. + if (edgeHooks !== null) edgeHooks.report(owner); if (comp.errored) { // A client hole read outside a Loading discovery pass must fail loudly // rather than wedge the stream (see clientHoleRead). Identity check on @@ -1648,9 +1578,6 @@ function createSyncMemo( let epoch: number | undefined; // See ServerComputation.moving. let moving = false; - // Dependency edges: what the last compute read, re-reported on read. - let memoReads: Set | null = null; - function pull(): T | undefined { // Inlined `runWithOwner` — avoids the per-pull `() => compute(value)` // closure alloc that `runWithOwner(owner, fn)` would force. Hot for the @@ -1666,19 +1593,15 @@ function createSyncMemo( // (#2801, generalized to all retry paths in #2900). resetOwnerForRerun(owner); const reads = asyncReads; - const prevSink = readSink; - const sink: ReadSink | null = identities ? (readSink = { s: null }) : null; // One handler frame (see `runWithOwner`): the normal path restores // inline, the catch restores and re-raises. let result: T; try { - result = compute(value) as T; + result = ( + edgeHooks !== null ? edgeHooks.run(owner, () => compute(value)) : compute(value) + ) as T; } catch (err) { currentOwner = prev; - if (sink !== null) { - readSink = prevSink; - memoReads = sink.s; - } if (asyncReads !== reads) moving = true; if (err instanceof NotReadyError) { // Don't latch a value — the engine re-pulls — but keep the signal @@ -1694,10 +1617,6 @@ function createSyncMemo( throw err; } currentOwner = prev; - if (sink !== null) { - readSink = prevSink; - memoReads = sink.s; - } if (asyncReads !== reads) moving = true; value = result; error = undefined; @@ -1722,7 +1641,8 @@ function createSyncMemo( return (() => { if (moving) asyncReads++; if (cached && (!ctx?.commitEpoch || ctx.commitEpoch() === epoch)) { - if (memoReads !== null) reportReads(memoReads); + // Dependency edges: a read re-reports what the last compute read. + if (edgeHooks !== null) edgeHooks.report(owner); if (errored) throw error; return value; } @@ -1734,8 +1654,9 @@ function createSyncMemo( // Stale epoch (a commit happened since this computed — frame renders // only) or never computed: (re)pull. Purity is the memo contract, so an // epoch recompute is the client's source-driven re-run, pull-paced. + if (edgeHooks === null) return pull(); const v = pull(); - if (memoReads !== null) reportReads(memoReads); + edgeHooks.report(owner); return v; }) as SourceAccessor; } @@ -2457,7 +2378,7 @@ function serverEffect( ): void { const ssrSource = options?.ssrSource; if (ssrSource === "client") { - markFillUntracked(); + if (edgeHooks !== null) edgeHooks.untracked(); createOwner(); return; } @@ -2579,7 +2500,7 @@ export function createTrackedEffect( ): void { // No-op on server, but allocate computation ID. Its reads are the // client's only, so a slot fill running one has edges the server misses. - markFillUntracked(); + if (edgeHooks !== null) edgeHooks.untracked(); const o = getOwner(); if (o?.id != null) getNextChildId(o); } @@ -2885,10 +2806,7 @@ export function createProjection( const ctx = sharedConfig.context; // Dependency edges: a projection's readers are not tracked (store // reads report nothing), so the signals its compute reads are WIDE. - if (ctx && identities) { - const compute = fn; - fn = ((...args: [T]) => wideReads(ctx, () => compute(...args))) as typeof fn; - } + if (ctx && edgeHooks !== null) fn = edgeHooks.projection(ctx, fn); const owner = createOwner(); // The projection's own owner, read at creation — never a swapped hole scope. const id = ownerId(owner); @@ -3487,7 +3405,7 @@ export function mapArray( rowOwner.id = formatChildId(origId, i); } rowOwner._childCount = 0; - if (sigCounts !== null) sigCounts.delete(rowOwner); + if (edgeHooks !== null) edgeHooks.reset(rowOwner); s.push( options.keyed === false ? indexes @@ -3585,7 +3503,7 @@ export function repeat( rowOwner.id = formatChildId(origId, i); } rowOwner._childCount = 0; - if (sigCounts !== null) sigCounts.delete(rowOwner); + if (edgeHooks !== null) edgeHooks.reset(rowOwner); out[i] = mapFn(i + offset); } } finally { diff --git a/packages/solid/test/server/signal-identities.spec.ts b/packages/solid/test/server/signal-identities.spec.ts new file mode 100644 index 000000000..4e66e9c2d --- /dev/null +++ b/packages/solid/test/server/signal-identities.spec.ts @@ -0,0 +1,94 @@ +/** @vitest-environment node */ +/** + * Dependency edges on the server: plain-signal identities and read tracking + * (edges.ts). Off until the frames server installs them, so a render without + * server components names nothing and tracks nothing. On, a plain signal + * created under an id-bearing owner is named `~`, n restarting + * whenever the owner's child counter does (dispose, retry reset), as the + * client names it; a slot fill's reads report those names, through memos too. + */ +import { afterEach, describe, expect, test } from "vitest"; +import { + createMemo, + createOwner, + createRoot, + createSignal, + resetOwnerForRerun, + runWithOwner +} from "../../src/server/signals.js"; +import { setServerSignalIdentities, trackFillReads } from "../../src/server/edges.js"; +import { sharedConfig } from "../../src/server/shared.js"; + +afterEach(() => { + setServerSignalIdentities(false); + sharedConfig.context = undefined; +}); + +/** A render that asked for edges. */ +function edgesRender() { + setServerSignalIdentities(true); + sharedConfig.context = { edges: true } as any; +} + +const readsOf = (fn: () => unknown) => [...(trackFillReads(fn).reads || [])]; + +describe("server plain-signal identities", () => { + test("off: nothing is named or tracked", () => { + const reads = createRoot( + () => { + const [a] = createSignal(1); + return readsOf(() => a()); + }, + { id: "off" } + ); + expect(reads).toEqual([]); + }); + + test("installed, but the render did not ask: nothing is named", () => { + setServerSignalIdentities(true); + sharedConfig.context = { edges: false } as any; + const reads = createRoot( + () => { + const [a] = createSignal(1); + return readsOf(() => a()); + }, + { id: "plain" } + ); + expect(reads).toEqual([]); + }); + + test("on: named per id-bearing owner in creation order; fills see reads through memos", () => { + edgesRender(); + const reads = createRoot( + () => { + const [a] = createSignal(1); + const [b] = createSignal(2); + const sum = createMemo(() => a() + b()); + return [readsOf(() => a()), readsOf(() => sum())]; + }, + { id: "r" } + ); + expect(reads[0]).toEqual(["r~0"]); + expect(reads[1].sort()).toEqual(["r~0", "r~1"]); + }); + + test("the count restarts when the owner's children reset (a retry)", () => { + edgesRender(); + const names = createRoot( + () => { + const o = createOwner(); + const pass = () => + runWithOwner(o, () => { + const [s] = createSignal(0); + return readsOf(() => s()); + }); + const first = pass(); + resetOwnerForRerun(o); + return [first, pass()]; + }, + { id: "q" } + ); + expect(names[0]).toHaveLength(1); + expect(names[1]).toEqual(names[0]); + }); +}); diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 0d3463dfe..8e70ab9c2 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -45,11 +45,13 @@ import { trackFillReads } from "solid-js/internal"; -// Dependency edges: a server that renders server components names its plain -// signals (`~`, as the client's signals core does) so slot fills -// can record which ones they read. Installed at load, ahead of any request; -// an app that never loads the frames server names nothing. A no-op in the -// client build (a browser test may load this module beside the client). +// Dependency edges: the frames server installs the server's signal-naming +// hooks (`~`, as the client's signals core names them) so slot +// fills can record which plain signals they read. They act only inside a +// render whose context has `edges` set (renderToStream sets it when the +// server-components codec is among its plugins); every other render takes +// the plain paths. A no-op in the client build (a browser test may load this +// module beside the client). setServerSignalIdentities(true); // EXPERIMENTAL — the frames/server-components surface ships as an @@ -1485,7 +1487,10 @@ export function createDocumentSlotProps(clientProps, frameId) { let wideSent = 0; const depsContext = sharedConfig.context; const noteDeps = (occurrence: string, reads: Set | null, untracked: boolean) => { - if (!depsContext || !depsContext.serialize) return; + // Records only from a render that names its signals (`edges`, set at + // render start when the server-components codec is configured): without + // them the client mounts everything on a write, as before. + if (!depsContext || !depsContext.serialize || !(depsContext as any).edges) return; const own = `sc-${frameId}-${occurrence}-`; const batch = () => { if (depsBatch) return depsBatch; diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index f70a448da..167705f8a 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -2769,6 +2769,11 @@ export function renderToStream(code, options = {}) { // frame stream (`options.sink`). Read by the server runtime's dev check // on undeclared unbounded sources, which only a document render pays for. document: !options.sink, + // Dependency edges (deferred hydration): a render configured with the + // server-components codec names its plain signals and records which ones + // each slot fill reads (solid's server edges.ts). Every other render + // takes the plain paths. + edges: !!options.plugins && options.plugins.some(p => p.tag === "solid/server-component"), // The document face's live-hole carrier (Stage 4). Components render // under per-component context CLONES (spread copies), so a mutation on // the clone a server component armed under never reaches this root From 86e3cb547132db39bad93f084ed0ba00c54c5a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:35:25 +0000 Subject: [PATCH 5/8] perf(solid): install server edge hooks only while an edges render runs frame-sink installed the edge hooks at module load, so any process that imported frames paid the hook calls on every createSignal, memo and scope in every render, SC or not. The hooks are now refcounted: renderToStream with the server-component plugin installs them when it starts and removes them when its render is disposed or throws. The hooks still check the render context for interleaved async renders, and their non-edges paths bail before allocating. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/solid/src/index.ts | 6 +- packages/solid/src/internal.ts | 7 +- packages/solid/src/server/edges.ts | 35 ++++++-- packages/solid/src/server/index.ts | 4 +- packages/solid/src/server/signals.ts | 10 ++- .../test/server/signal-identities.spec.ts | 25 ++++-- packages/web/frames/src/frame-sink.ts | 10 --- packages/web/src/server.ts | 90 +++++++++++-------- 8 files changed, 116 insertions(+), 71 deletions(-) diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 85ebd3076..4c702c5b7 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -186,8 +186,10 @@ export function creationStamp(): number { export { setWriteHook } from "@solidjs/signals"; /** @internal Dependency edges: name plain signals so a write reports which one; dev read watch. */ export { setSignalIdentities, setReadWatch, signalName } from "@solidjs/signals"; -/** @internal Dependency edges: server-only (the frames server names plain signals). */ -export function setServerSignalIdentities(_on: boolean): void {} +/** @internal Dependency edges: server-only (an edges render names plain signals). Client: no-op. */ +export function beginEdgesRender(): () => void { + return () => {}; +} /** @internal Dependency edges: server-only read tracking; the client runs `fn`. */ export function trackFillReads(fn: () => T): { result: T; diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index 7e4fe678d..4ae9c7adf 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -133,8 +133,11 @@ export const setWriteHook: (hook: ((node?: object) => void) | null) => void = core.setWriteHook as any; /** Dependency edges. Client: name plain signals (`~`) from now on. Server: no-op. */ export const setSignalIdentities: (on: boolean) => void = core.setSignalIdentities; -/** Dependency edges. Server: name and track plain signals from now on. Client: no-op. */ -export const setServerSignalIdentities: (on: boolean) => void = core.setServerSignalIdentities; +/** + * Dependency edges. Server: a render that names and tracks plain signals + * starts; call the returned function when it ends. Client: no-op. + */ +export const beginEdgesRender: () => () => void = core.beginEdgesRender; /** Dependency edges, dev only. Client: watch every node read. Server: no-op. */ export const setReadWatch: (watch: ((node: object) => void) | null) => void = core.setReadWatch; /** Dependency edges. Client: a plain signal's identity, if it was named. Server: `undefined`. */ diff --git a/packages/solid/src/server/edges.ts b/packages/solid/src/server/edges.ts index 173be7e47..e6aaff2fc 100644 --- a/packages/solid/src/server/edges.ts +++ b/packages/solid/src/server/edges.ts @@ -1,8 +1,9 @@ // Deferred hydration, dependency edges: plain-signal identities and read -// tracking on the server. Only the frames server loads this (it installs the -// hooks at its module load, `setServerSignalIdentities`); the render paths in -// signals.ts hold one null-checked slot, and the hooks act only inside a -// render that asked for edges (`on`), so other renders take the plain paths. +// tracking on the server. The render paths in signals.ts hold one +// null-checked slot, filled only while an edges render is in flight +// (`beginEdgesRender`, renderToStream with the server-components codec); the +// hooks act only inside such a render's context (`on`), allocating nothing +// for any other. // // A plain signal created under an id-bearing owner is named `~` // (n counts plain signals under that owner since its child counter last @@ -57,6 +58,7 @@ function reportReads(reads: Set): void { } const hooks: EdgeHooks = { + on, signal(read: () => T): () => T { let o: any = getOwner(); if (!o || !on()) return read; @@ -71,7 +73,6 @@ const hooks: EdgeHooks = { }; }, run(owner: object, fn: () => T): T { - if (!on()) return fn(); const prev = readSink; const sink: ReadSink = (readSink = { s: null }); try { @@ -128,9 +129,27 @@ const hooks: EdgeHooks = { } }; -/** @internal Dependency edges: name and track plain signals from now on (`on`). */ -export function setServerSignalIdentities(on: boolean): void { - setEdgeHooks(on ? hooks : null); +// Edges renders in flight. The hooks are installed only while one is: a +// process that never starts one (or has none running) keeps the plain paths +// with nothing behind the slot. Interleaved async renders still check their +// own context (`on`). +let active = 0; +/** @internal Edges renders in flight (tests). */ +export function edgesRenders(): number { + return active; +} +/** + * @internal An edges render starts (renderToStream with the server-components + * codec): install the hooks. Call the returned function once when it ends. + */ +export function beginEdgesRender(): () => void { + if (active++ === 0) setEdgeHooks(hooks); + let ended = false; + return () => { + if (ended) return; + ended = true; + if (--active === 0) setEdgeHooks(null); + }; } /** diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 7febfc692..811d49823 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -160,10 +160,10 @@ export { } from "./signals.js"; export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; /** @internal Dependency edges (the frames server installs them). */ -export { trackFillReads, setServerSignalIdentities } from "./edges.js"; +export { trackFillReads, beginEdgesRender } from "./edges.js"; /** @internal Client-only: the server has no writes to observe. */ export function setWriteHook(_hook: ((node?: object) => void) | null): void {} -/** @internal Dependency edges: client-only (the server names through `setServerSignalIdentities`). */ +/** @internal Dependency edges: client-only (the server names through `beginEdgesRender`). */ export function setSignalIdentities(_on: boolean): void {} /** @internal Dependency edges: client-only. */ export function setReadWatch(_watch: ((node: object) => void) | null): void {} diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 6a06cac5e..cfeff67f5 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1237,9 +1237,11 @@ let asyncReads = 0; // carries none of it. /** @internal The dependency-edge hooks (edges.ts). */ export interface EdgeHooks { + /** The current render asked for edges. */ + on(): boolean; /** A plain signal created under the current owner: its named read. */ signal(read: () => T): () => T; - /** Run a memo's compute (keyed by its owner), keeping what it read. */ + /** Run a memo's compute (keyed by its owner), keeping what it read. Only when `on()`. */ run(owner: object, fn: () => T): T; /** A memo was read: report what its last compute read. */ report(owner: object): void; @@ -1418,7 +1420,7 @@ export function createMemo( try { return runWithOwner(owner, () => runWithObserver(comp, () => - edgeHooks !== null + edgeHooks !== null && edgeHooks.on() ? edgeHooks.run(owner, () => comp.compute(comp.value)) : comp.compute(comp.value) ) @@ -1598,7 +1600,9 @@ function createSyncMemo( let result: T; try { result = ( - edgeHooks !== null ? edgeHooks.run(owner, () => compute(value)) : compute(value) + edgeHooks !== null && edgeHooks.on() + ? edgeHooks.run(owner, () => compute(value)) + : compute(value) ) as T; } catch (err) { currentOwner = prev; diff --git a/packages/solid/test/server/signal-identities.spec.ts b/packages/solid/test/server/signal-identities.spec.ts index 4e66e9c2d..ef116c25e 100644 --- a/packages/solid/test/server/signal-identities.spec.ts +++ b/packages/solid/test/server/signal-identities.spec.ts @@ -13,20 +13,23 @@ import { createOwner, createRoot, createSignal, + edgeHooks, resetOwnerForRerun, runWithOwner } from "../../src/server/signals.js"; -import { setServerSignalIdentities, trackFillReads } from "../../src/server/edges.js"; +import { beginEdgesRender, edgesRenders, trackFillReads } from "../../src/server/edges.js"; import { sharedConfig } from "../../src/server/shared.js"; +let end: (() => void) | undefined; afterEach(() => { - setServerSignalIdentities(false); + end?.(); + end = undefined; sharedConfig.context = undefined; }); -/** A render that asked for edges. */ +/** A render that asked for edges is in flight. */ function edgesRender() { - setServerSignalIdentities(true); + end = beginEdgesRender(); sharedConfig.context = { edges: true } as any; } @@ -45,7 +48,7 @@ describe("server plain-signal identities", () => { }); test("installed, but the render did not ask: nothing is named", () => { - setServerSignalIdentities(true); + end = beginEdgesRender(); sharedConfig.context = { edges: false } as any; const reads = createRoot( () => { @@ -91,4 +94,16 @@ describe("server plain-signal identities", () => { expect(names[0]).toHaveLength(1); expect(names[1]).toEqual(names[0]); }); + + test("the hooks are installed only while an edges render is in flight", () => { + const a = beginEdgesRender(); + const b = beginEdgesRender(); + expect(edgesRenders()).toBe(2); + a(); + a(); + expect(edgesRenders()).toBe(1); + b(); + expect(edgesRenders()).toBe(0); + expect(edgeHooks).toBeNull(); + }); }); diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 8e70ab9c2..58c392eb4 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -41,19 +41,9 @@ import { ssrSanitizeError, creationStamp, shareAsyncIterable, - setServerSignalIdentities, trackFillReads } from "solid-js/internal"; -// Dependency edges: the frames server installs the server's signal-naming -// hooks (`~`, as the client's signals core names them) so slot -// fills can record which plain signals they read. They act only inside a -// render whose context has `edges` set (renderToStream sets it when the -// server-components codec is among its plugins); every other render takes -// the plain paths. A no-op in the client build (a browser test may load this -// module beside the client). -setServerSignalIdentities(true); - // EXPERIMENTAL — the frames/server-components surface ships as an // experimental preview, excluded from the 2.0 stability guarantee: API // shapes and the wire format may change between prereleases (RFC 11). diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 167705f8a..bd2f86dc7 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -26,7 +26,8 @@ import { sourceOwners, ssrScope as scope, ssrSanitizeError, - reportServerError + reportServerError, + beginEdgesRender } from "solid-js/internal"; import type { Owner } from "solid-js"; import type { ServerErrorSite } from "solid-js/internal"; @@ -3085,44 +3086,55 @@ export function renderToStream(code, options = {}) { const onAbort = signal ? () => abandon("signal", !!(signal.reason && signal.reason.quiet === true)) : undefined; - let html = root( - d => { - dispose = () => { - // The render is over: no later read finds its trace (see above). - context.trace = undefined; - if (onAbort) signal.removeEventListener("abort", onAbort); - d(); - }; - rootOwner = claimRenderRoot(context); - if (requestEvent) claimEventRender(requestEvent, context); - let res; - try { - res = resolveSSRNode(escape(code())); - } catch (err) { - // The first pass throws out of `renderToStream` to the caller: the - // hook hears it first, as for `renderToString`. - reportServerError( - err, - { kind: "render", handling: "failed" }, - null, - options.onError, - errorScope - ); - throw err; - } - if (!res.h.length) return res.t[0]; - rootHoles = []; - let out = res.t[0]; - for (let i = 0; i < res.h.length; i++) { - const id = nextHoleId++; - rootHoles.push({ id, fn: res.h[i] }); - out += `` + res.t[i + 1]; - } - for (const p of res.p) blockingPromises.add(p); - return out; - }, - { id: renderId } - ); + // Dependency edges: the server's signal-naming hooks are installed while + // an edges render is in flight; this one releases them at its final + // dispose, or here if the root pass throws. + const endEdges = context.edges ? beginEdgesRender() : undefined; + let html; + try { + html = root( + d => { + dispose = () => { + // The render is over: no later read finds its trace (see above). + context.trace = undefined; + if (onAbort) signal.removeEventListener("abort", onAbort); + if (endEdges) endEdges(); + d(); + }; + rootOwner = claimRenderRoot(context); + if (requestEvent) claimEventRender(requestEvent, context); + let res; + try { + res = resolveSSRNode(escape(code())); + } catch (err) { + // The first pass throws out of `renderToStream` to the caller: the + // hook hears it first, as for `renderToString`. + reportServerError( + err, + { kind: "render", handling: "failed" }, + null, + options.onError, + errorScope + ); + throw err; + } + if (!res.h.length) return res.t[0]; + rootHoles = []; + let out = res.t[0]; + for (let i = 0; i < res.h.length; i++) { + const id = nextHoleId++; + rootHoles.push({ id, fn: res.h[i] }); + out += `` + res.t[i + 1]; + } + for (const p of res.p) blockingPromises.add(p); + return out; + }, + { id: renderId } + ); + } catch (err) { + if (endEdges) endEdges(); + throw err; + } if (onAbort) { // A request already gone when the render starts has nobody to render // for: tear down now, after the root pass so the finding counts what it From 1406ce2b8dbae410a7320e36d40c51a7816c1dae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:26:48 +0000 Subject: [PATCH 6/8] perf(solid): keep the row loops free of edge hook calls The search-results lane regressed under CodSpeed (Node 24): the per-row `edgeHooks.reset` call added bytecode to mapArray's row loop, which moved that loop's Maglev tier-up from after the measured iteration into it (about 1M instructions of compilation and an immediate deopt). The rows' plain-signal counts now remember the owner id they were taken under, so a reused row owner moving on to the next row's id starts at 0 by itself; `reset` runs once per pass, outside the loop, for retries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/solid/src/server/edges.ts | 30 ++++++++++++------- packages/solid/src/server/signals.ts | 14 ++++++--- .../test/server/signal-identities.spec.ts | 23 ++++++++++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/solid/src/server/edges.ts b/packages/solid/src/server/edges.ts index e6aaff2fc..acf07a351 100644 --- a/packages/solid/src/server/edges.ts +++ b/packages/solid/src/server/edges.ts @@ -13,7 +13,7 @@ // sources — or the request's WIDE sink, for reads whose readers are not // tracked (a projection's compute): a write to a wide signal mounts // everything left. -import { getOwner, materializeId, setEdgeHooks, type EdgeHooks } from "./signals.js"; +import { formatChildId, getOwner, materializeId, setEdgeHooks, type EdgeHooks } from "./signals.js"; import { sharedConfig } from "./shared.js"; // Per render: only a render whose context has `edges` set (renderToStream, @@ -31,17 +31,27 @@ let readSink: ReadSink | null = null; // incomplete, and it mounts on every named write. let fillSink: ReadSink | null = null; -// Plain signals created under each id-bearing owner. An owner's entry is -// dropped with its child counter: by a cleanup on the owner (dispose, retry -// reset, rerun) or by `reset` (a reused row owner). -const counts = new WeakMap(); +// Plain signals created under each id-bearing owner, beside the id they +// were counted under. An owner's entry is dropped with its child counter: by +// a cleanup on the owner (dispose, retry reset, rerun) or by `reset` (a +// reused row owner's pass); a count taken under another id is a previous +// row's (a reused row owner moves its id on per row), so it reads as 0. +const counts = new WeakMap(); +// The id `materializeId` would give, without materializing it. +function idOf(owner: any): unknown { + return owner._scopeSlot >= 0 ? formatChildId(owner.id, owner._scopeSlot) : owner.id; +} +function count(owner: any): number { + const c = counts.get(owner); + return c !== undefined && c[0] === idOf(owner) ? c[1] : 0; +} function counted(owner: any, n: number): void { if (!counts.has(owner)) { const drop = () => void counts.delete(owner); const d = owner._disposal; owner._disposal = !d ? drop : Array.isArray(d) ? (d.push(drop), d) : [d, drop]; } - counts.set(owner, n); + counts.set(owner, [idOf(owner), n]); } // What each memo's last compute read (keyed by its owner), re-reported when @@ -64,7 +74,7 @@ const hooks: EdgeHooks = { if (!o || !on()) return read; while (o._transparent && o._parent) o = o._parent; if (o.id == null) return read; - const n = counts.get(o) || 0; + const n = count(o); counted(o, n + 1); const sid = materializeId(o) + "~" + n; return () => { @@ -105,7 +115,7 @@ const hooks: EdgeHooks = { }, enter(key: object, owner: object, resumed: boolean): number { if (!on()) return 0; - const prev = counts.get(owner) || 0; + const prev = count(owner); counted(owner, resumed ? scopes.get(key)?.resume || 0 : 0); return prev; }, @@ -113,14 +123,14 @@ const hooks: EdgeHooks = { if (!on()) return; let st = scopes.get(key); if (st === undefined) scopes.set(key, (st = { mark: 0, resume: 0 })); - st.mark = counts.get(owner) || 0; + st.mark = count(owner); }, leave(key: object, owner: object, prev: number, keep: 0 | 1 | 2) { if (!on()) return; if (keep !== 0) { let st = scopes.get(key); if (st === undefined) scopes.set(key, (st = { mark: 0, resume: 0 })); - st.resume = keep === 1 ? st.mark : counts.get(owner) || 0; + st.resume = keep === 1 ? st.mark : count(owner); } counted(owner, prev); }, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index cfeff67f5..46a666b3c 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -192,7 +192,8 @@ let currentOwner: SSROwner | null = null; const OWNER_POOL_MAX = 4096; const ownerPool: SSROwner[] = []; -function formatChildId(prefix: string, id: number): string { +/** @internal */ +export function formatChildId(prefix: string, id: number): string { const num = id.toString(36); const len = num.length - 1; return prefix + (len ? String.fromCharCode(64 + len) : "") + num; @@ -1259,7 +1260,10 @@ export interface EdgeHooks { mark(key: object, owner: object): void; /** The scope swaps out: keep its mark (1) or its count (2) for a resumed pass; restore `prev`. */ leave(key: object, owner: object, prev: number, keep: 0 | 1 | 2): void; - /** `owner`'s child counter was reset without disposing it (a reused row owner). */ + /** + * A reused row owner starts a pass: its plain-signal count restarts (and + * restarts again whenever its id moves on to the next row). + */ reset(owner: object): void; } /** @internal */ @@ -3386,6 +3390,9 @@ export function mapArray( // Reset per pass so retries re-synthesize row/fallback ids from 0, // like the client's fresh row owners on its first successful pass. rowOwner._childCount = 0; + // Edges: the rows' plain-signal counts restart with each pass (a row's + // id moving on restarts its own; nothing in the row loop itself). + if (edgeHooks !== null) edgeHooks.reset(rowOwner); if (items && items.length) { runWithOwner(rowOwner as unknown as Owner, () => { // Own owner, not a swapped hole scope: a row's holes swap it only @@ -3409,7 +3416,6 @@ export function mapArray( rowOwner.id = formatChildId(origId, i); } rowOwner._childCount = 0; - if (edgeHooks !== null) edgeHooks.reset(rowOwner); s.push( options.keyed === false ? indexes @@ -3493,6 +3499,7 @@ export function repeat( const len = count(); const offset = options.from?.() || 0; rowOwner._childCount = 0; + if (edgeHooks !== null) edgeHooks.reset(rowOwner); if (!len) { if (!options.fallback) return []; const fo = runWithOwner(rowOwner as unknown as Owner, () => createOwner()); @@ -3507,7 +3514,6 @@ export function repeat( rowOwner.id = formatChildId(origId, i); } rowOwner._childCount = 0; - if (edgeHooks !== null) edgeHooks.reset(rowOwner); out[i] = mapFn(i + offset); } } finally { diff --git a/packages/solid/test/server/signal-identities.spec.ts b/packages/solid/test/server/signal-identities.spec.ts index ef116c25e..27aa878a8 100644 --- a/packages/solid/test/server/signal-identities.spec.ts +++ b/packages/solid/test/server/signal-identities.spec.ts @@ -14,6 +14,7 @@ import { createRoot, createSignal, edgeHooks, + mapArray, resetOwnerForRerun, runWithOwner } from "../../src/server/signals.js"; @@ -95,6 +96,28 @@ describe("server plain-signal identities", () => { expect(names[1]).toEqual(names[0]); }); + test("rows of one list count their own signals, from 0 on every pass", () => { + edgesRender(); + const passes = createRoot( + () => { + const [items] = createSignal(["a", "b", "c"]); + const rows = mapArray(items, () => { + const [s] = createSignal(0); + const [t] = createSignal(0); + return readsOf(() => (s(), t())); + }); + return [rows(), rows()]; + }, + { id: "l" } + ); + const [first] = passes; + const names = first.flat(); + expect(new Set(names).size).toBe(6); + // Each row's two signals: `~0` and `~1`. + for (const row of first) expect(row.map(n => n.split("~")[1])).toEqual(["0", "1"]); + expect(passes[1]).toEqual(first); + }); + test("the hooks are installed only while an edges render is in flight", () => { const a = beginEdgesRender(); const b = beginEdgesRender(); From b59c6c40507aa59e5de97cbcc8c0353dc8989530 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:30:05 +0000 Subject: [PATCH 7/8] feat(web): name plain stores by their root for dependency edges A plain store root is named like a plain signal (`~`, on the same per-owner count), and the whole store goes by that one name. On the server, any read of the store inside a slot fill, at any depth, through iteration or memos, records the root's name; a store held inside another store makes the holder wide. On the client, a setter call on the store or on any proxy nested in it reports the root at the setter's entry, so the store's readers mount before the write applies. Projections, optimistic stores, unnamed stores and stores whose root cannot be placed still mount everything. Size-Exception: dependency edges for deferred hydration now cover stores; frames client and server-component pages, see scripts/size/scenarios.js. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012xU4iGQqRKP12d5Nkr4BA2 --- packages/signals/src/core/core.ts | 7 +- packages/signals/src/core/identities.ts | 22 +- packages/signals/src/index.ts | 2 + packages/signals/src/store/edges.ts | 38 ++++ packages/signals/src/store/store.ts | 5 +- .../signals/tests/signal-identities.test.ts | 82 ++++++- packages/solid/src/index.ts | 2 +- packages/solid/src/internal.ts | 5 + packages/solid/src/server/edges.ts | 68 +++++- packages/solid/src/server/index.ts | 4 + packages/solid/src/server/signals.ts | 15 +- .../test/server/signal-identities.spec.ts | 49 +++++ packages/web/frames/src/client.ts | 31 +-- .../__artifacts__/frame-dependency-edges.json | 2 +- .../test/harness/frame-dependency-edges.tsx | 44 +++- .../frame-dependency-edges-derived.spec.tsx | 2 +- .../frame-dependency-edges-helpers.tsx | 1 + .../frame-dependency-edges-local.spec.tsx | 7 +- ...frame-dependency-edges-projection.spec.tsx | 27 +++ ...me-dependency-edges-store-unnamed.spec.tsx | 24 +++ .../frame-dependency-edges-store.spec.tsx | 23 +- .../frame-dependency-edges-wide.spec.tsx | 2 +- .../hydration/frame-dependency-edges.spec.tsx | 2 +- .../server/frame-dependency-edges.spec.tsx | 14 +- scripts/size/floor-caps.json | 8 +- scripts/size/scenarios.js | 200 ++++++++++-------- 26 files changed, 532 insertions(+), 154 deletions(-) create mode 100644 packages/signals/src/store/edges.ts create mode 100644 packages/web/test/hydration/frame-dependency-edges-projection.spec.tsx create mode 100644 packages/web/test/hydration/frame-dependency-edges-store-unnamed.spec.tsx diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 083d79572..5e610220f 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -2030,15 +2030,16 @@ export function captureWriteSnapshot(el: Signal | Computed, current: T) } /** - * A callback run before every signal or store write lands, while set. The + * A callback run before every signal or store write lands, while set, with + * the written signal or the store proxy whose setter runs. The * server-components client sets it while slot occurrences are left * unmounted after hydration (deferred hydration): a write may be one an * unmounted component would have observed, so the client mounts what is * left before the write applies, and clears the hook. `null` otherwise. */ -export let writeHook: ((node?: Signal | Computed) => void) | null = null; +export let writeHook: ((node?: object) => void) | null = null; /** @internal */ -export function setWriteHook(hook: ((node?: Signal | Computed) => void) | null): void { +export function setWriteHook(hook: ((node?: object) => void) | null): void { writeHook = hook; } diff --git a/packages/signals/src/core/identities.ts b/packages/signals/src/core/identities.ts index 04a98ec6d..291aa7736 100644 --- a/packages/signals/src/core/identities.ts +++ b/packages/signals/src/core/identities.ts @@ -1,12 +1,13 @@ import { CONFIG_TRANSPARENT } from "./constants.js"; import { DEV } from "./dev.js"; import { getOwner } from "./owner.js"; -import type { Owner, Signal } from "./types.js"; +import type { Owner } from "./types.js"; // Deferred hydration, dependency edges: plain-signal identities. // While on (`setSignalIdentities`; the server-components client turns it on // before hydrating), a plain signal created under an id-bearing owner is -// named `~`, n counting the plain signals created under that +// named `~`, n counting the plain signals (and plain store +// roots: `createStore` over an object, store/edges.ts) created under that // owner in its current pass (transparent owners defer to the nearest // id-bearing one, as child ids do). The server names the same signal the // same way and restarts the count on every re-run, so an edge it shipped (a @@ -24,7 +25,7 @@ export function signalName(node: object | undefined): string | undefined { return node === undefined ? undefined : names.get(node); } -function identify(node: Signal): void { +function identify(node: object): void { let o = getOwner(); if (!o) return; while (o._config & CONFIG_TRANSPARENT && o._parent) o = o._parent; @@ -32,7 +33,11 @@ function identify(node: Signal): void { const gen = (o as any)._depGen as number | undefined; let c = counts!.get(o); if (c === undefined || c[0] !== gen) counts!.set(o, (c = [gen, 0])); - names.set(node, o.id + "~" + c[1]++); + const sid = o.id + "~" + c[1]++; + // A store root named twice (one object made into two stores, or a store + // handed to `createStore`) is one target under two server names: its + // writes cannot be attributed (an empty name). + names.set(node, names.has(node) ? "" : sid); } /** @internal Name plain signals from now on (`on`), or stop. */ @@ -42,11 +47,12 @@ export function setSignalIdentities(on: boolean): void { } /** - * @internal Called with every plain signal `createSignal` makes while set - * (the namer above). Never set unless `setSignalIdentities` is imported, so - * a bundle without it keeps only a null check. + * @internal Called with every plain signal `createSignal` makes, and every + * plain store root `createStore` makes, while set (the namer above). Never + * set unless `setSignalIdentities` is imported, so a bundle without it keeps + * only a null check. */ -export let signalHook: ((node: Signal) => void) | null = null; +export let signalHook: ((node: object) => void) | null = null; /** * @internal Dev only: `watch` is called with every node read while set (the diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index adcef3cb0..082b8b46f 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -41,6 +41,8 @@ export { setConsoleFooter } from "./core/index.js"; /** @internal The pre-write hook (`setWriteHook` in core/core.ts); the server-components client's deferred hydration. */ /** @internal Plain-signal identities and the dev read watch (dependency edges; the server-components client). */ export { setWriteHook, setSignalIdentities, setReadWatch, signalName } from "./core/index.js"; +/** @internal Store dependency edges: the name a store proxy's writes go by (the server-components client). */ +export { storeName } from "./store/edges.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js"; /** * Observe tier (diagnostics channel, attribution hook slot + interaction diff --git a/packages/signals/src/store/edges.ts b/packages/signals/src/store/edges.ts new file mode 100644 index 000000000..7ac3d05dc --- /dev/null +++ b/packages/signals/src/store/edges.ts @@ -0,0 +1,38 @@ +/** + * Deferred hydration, dependency edges for stores. Only the frames client + * imports this module, so nothing here ships without it. + * + * A plain store root (`createStore` over an object) is named like a plain + * signal (core/identities.ts: `~`, on the same count), and the + * whole store goes by that one name: the server records it for any read of + * the store, at any depth, and a setter call on the store — on its root or + * on a proxy nested in it — is a write to it. Its readers mount at the + * setter's entry, before the draft runs, as a plain signal's do. + */ +import { CONFIG_SLOT_NODE } from "../core/constants.js"; +import { signalName } from "../core/identities.js"; +import type { StoreTarget } from "./target.js"; +import { $TARGET } from "./types.js"; + +/** + * @internal The name of the store `x` belongs to — `x` a store proxy (a + * setter's entry) or one of its property nodes (the setter notifying it) — + * or undefined when it has none or it cannot be placed: a projection or + * optimistic family, a store chained over another store's proxy, a root + * named twice, or a proxy reached through a store other than the one it + * was named as the root of. + */ +export function storeName(x: object | undefined): string | undefined { + if (x === undefined) return undefined; + let t: StoreTarget | undefined = + (x as any)._config & CONFIG_SLOT_NODE ? (x as any)._host : (x as any)[$TARGET]; + if (t === undefined) return undefined; + // Up to the root; a target on the way named as a root of its own is one + // object in two stores. + while (t.u !== null) { + if (t.fam !== null || t.ch || signalName(t) !== undefined) return undefined; + t = t.u; + } + if (t.fam !== null || t.ch) return undefined; + return signalName(t) || undefined; +} diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 408925a52..dcd87ae3b 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -73,6 +73,7 @@ import { registerGraph, warnStrictReadUntracked } from "../core/dev.js"; +import { signalHook } from "../core/identities.js"; import { setSlotUnobserved } from "../core/graph.js"; import { getObserver, getOwner } from "../core/owner.js"; import { insertIntoHeap } from "../core/heap.js"; @@ -2304,7 +2305,7 @@ function devGuardStoreSetterResult(result: unknown): void { } export function storeSetter(proxy: T, fn: (draft: T) => T | void, guard = true): void { - if (writeHook !== null) writeHook(); + if (writeHook !== null) writeHook(proxy as object); if (__DEV__ && guard) devGuardStoreSetterWrite(); const target: StoreTarget = (proxy as any)[$TARGET]; const prevScopes = writeScopes; @@ -2376,6 +2377,8 @@ export function createStore>( throw new Error("createStore({ shallow }): value is already a store proxy"); } const proxy = wrap(initialValue); + // Dependency edges (store/edges.ts): the root is named like a plain signal. + if (signalHook !== null) signalHook((proxy as any)[$TARGET]); if (shallow) { ((proxy as any)[$TARGET] as StoreTarget).s = true; markRawIngest(initialValue); diff --git a/packages/signals/tests/signal-identities.test.ts b/packages/signals/tests/signal-identities.test.ts index faf17709a..66fed664a 100644 --- a/packages/signals/tests/signal-identities.test.ts +++ b/packages/signals/tests/signal-identities.test.ts @@ -1,12 +1,23 @@ // Plain-signal identities (deferred hydration's dependency edges). While on, // a plain signal created under an id-bearing owner is named `~`, n // counting the plain signals created under the nearest id-bearing owner; -// the count restarts when the owner re-runs, as child ids do. The pre-write -// hook receives the named node (none for a store or a derived signal), and the dev -// read watch sees it on every read. +// the count restarts when the owner re-runs, as child ids do. A plain store +// root is named on the same count, and every write to the store goes by its +// root's name. The pre-write hook receives the named node (or the store +// proxy whose setter runs, then the store's property nodes as it +// notifies), and the dev read watch sees it on every read. import { afterEach, describe, expect, test } from "vitest"; import { setReadWatch, setSignalIdentities, setWriteHook, signalName } from "../src/core/index.js"; -import { createMemo, createRoot, createSignal, createStore, flush } from "../src/index.js"; +import { + createMemo, + createOptimisticStore, + createProjection, + createRoot, + createSignal, + createStore, + flush, + storeName +} from "../src/index.js"; afterEach(() => { setWriteHook(null); @@ -19,6 +30,15 @@ function hookedWrites() { setWriteHook(node => writes.push(signalName(node))); return writes; } +/** Every write-hook call's name, a store's included (deduped in order). */ +function hookedStoreWrites() { + const writes: (string | undefined)[] = []; + setWriteHook(node => { + const sid = signalName(node) ?? storeName(node); + if (writes[writes.length - 1] !== sid) writes.push(sid); + }); + return writes; +} describe("plain-signal identities", () => { test("named per id-bearing owner, in creation order; the hook receives the named node", () => { @@ -37,9 +57,59 @@ describe("plain-signal identities", () => { setA(1); setB(1); setD(2); + // The derived signal carries no name; the store is `r~2` (the hook gets + // its proxy, then its property node: `storeName` names both). + expect(writes).toEqual(["r~0", "r~1", undefined]); + const stores = hookedStoreWrites(); setS(s => void (s.n = 1)); - // The derived signal and the store write carry no name. - expect(writes).toEqual(["r~0", "r~1", undefined, undefined]); + expect(stores).toEqual(["r~2"]); + }); + + test("a store goes by its root's name, from its root or any proxy nested in it", () => { + setSignalIdentities(true); + const [store, setStore, before, after] = createRoot( + () => { + const before = createSignal(0)[1]; + const [store, setStore] = createStore({ user: { name: "a" }, tags: ["x"] }); + const after = createSignal(0)[1]; + return [store, setStore, before, after] as const; + }, + { id: "s" } + ); + expect(storeName(store)).toBe("s~1"); + expect(storeName(store.user)).toBe("s~1"); + expect(storeName(store.tags)).toBe("s~1"); + const writes = hookedStoreWrites(); + before(1); + setStore(s => void (s.user.name = "b")); + after(1); + expect(writes).toEqual(["s~0", "s~1", "s~2"]); + }); + + test("stores it cannot place have no name", () => { + setSignalIdentities(true); + const shared = { n: 0 }; + const [outside] = createStore({ n: 0 }); + const r = createRoot( + () => { + const [twice1] = createStore(shared); + const [twice2] = createStore(shared); + const proj = createProjection<{ v: number }>(() => {}, { v: 0 }); + const [opt] = createOptimisticStore({ n: 0 }); + const [plain] = createStore({ n: 0 }); + return { twice1, twice2, proj, opt, plain }; + }, + { id: "u" } + ); + // Created outside any owner; one object made into two stores (one target + // under two names); a projection; an optimistic store. + expect(storeName(outside)).toBeUndefined(); + expect(storeName(r.twice1)).toBeUndefined(); + expect(storeName(r.twice2)).toBeUndefined(); + expect(storeName(r.proj)).toBeUndefined(); + expect(storeName(r.opt)).toBeUndefined(); + // The count still advanced for the two createStore calls. + expect(storeName(r.plain)).toBe("u~2"); }); test("the count restarts when the owner re-runs", () => { diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 4c702c5b7..91e6c5adf 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -185,7 +185,7 @@ export function creationStamp(): number { /** @internal A callback run before every signal or store write lands (deferred hydration). */ export { setWriteHook } from "@solidjs/signals"; /** @internal Dependency edges: name plain signals so a write reports which one; dev read watch. */ -export { setSignalIdentities, setReadWatch, signalName } from "@solidjs/signals"; +export { setSignalIdentities, setReadWatch, signalName, storeName } from "@solidjs/signals"; /** @internal Dependency edges: server-only (an edges render names plain signals). Client: no-op. */ export function beginEdgesRender(): () => void { return () => {}; diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index 4ae9c7adf..9a14d48a5 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -142,6 +142,11 @@ export const beginEdgesRender: () => () => void = core.beginEdgesRender; export const setReadWatch: (watch: ((node: object) => void) | null) => void = core.setReadWatch; /** Dependency edges. Client: a plain signal's identity, if it was named. Server: `undefined`. */ export const signalName: (node: object | undefined) => string | undefined = core.signalName; +/** + * Dependency edges. Client: the name a store proxy's writes go by (its + * root's, when the root was named and can be placed). Server: `undefined`. + */ +export const storeName: (proxy: object | undefined) => string | undefined = core.storeName; /** * Dependency edges. Server: run a slot fill, returning the plain-signal * identities it read (through memos too) and whether it ran a computation diff --git a/packages/solid/src/server/edges.ts b/packages/solid/src/server/edges.ts index acf07a351..77bd0e8d5 100644 --- a/packages/solid/src/server/edges.ts +++ b/packages/solid/src/server/edges.ts @@ -1,5 +1,5 @@ -// Deferred hydration, dependency edges: plain-signal identities and read -// tracking on the server. The render paths in signals.ts hold one +// Deferred hydration, dependency edges: plain-signal and plain-store +// identities and read tracking on the server. The render paths in signals.ts hold one // null-checked slot, filled only while an edges render is in flight // (`beginEdgesRender`, renderToStream with the server-components codec); the // hooks act only inside such a render's context (`on`), allocating nothing @@ -67,8 +67,72 @@ function reportReads(reads: Set): void { for (const sid of reads) s.add(sid); } +// Plain stores: a root is named like a plain signal (on the same count) and +// read through a view that reports that one name for any read of it, at any +// depth (a property, `in`, its keys); the client writes the whole store by +// that name. Objects under it are viewed too. A store held inside another +// is returned as its own view (its reads are its own); the holder becomes +// wide, since a setter call on the holder can write the held store, whose +// direct readers the holder's name does not reach. +const RAW = Symbol(); +function viewable(v: any): boolean { + if (v === null || typeof v !== "object" || Object.isFrozen(v)) return false; + const p = Object.getPrototypeOf(v); + return p === Object.prototype || p === null || Array.isArray(v); +} +function storeView(raw: T, sid: string, views: WeakMap): T { + let view = views.get(raw) as T | undefined; + if (view !== undefined) return view; + const read = () => { + if (readSink !== null) (readSink.s ||= new Set()).add(sid); + }; + view = new Proxy(raw, { + get(t, k, r) { + if (k === RAW) return sid; + const v = Reflect.get(t, k, r); + if (typeof k === "symbol" || typeof v === "function") return v; + read(); + if (!viewable(v)) return v; + if ((v as any)[RAW] !== undefined) { + const ctx = sharedConfig.context as any; + if (ctx) ((ctx.edgeWide ||= { s: null, wide: true }).s ||= new Set()).add(sid); + return v; + } + // A non-configurable read-only property must read as itself. + const d = Reflect.getOwnPropertyDescriptor(t, k); + return d && !d.configurable && !d.writable ? v : storeView(v as object, sid, views); + }, + has(t, k) { + read(); + return Reflect.has(t, k); + }, + ownKeys(t) { + read(); + return Reflect.ownKeys(t); + }, + getOwnPropertyDescriptor(t, k) { + read(); + return Reflect.getOwnPropertyDescriptor(t, k); + } + }); + views.set(raw, view); + return view; +} + const hooks: EdgeHooks = { on, + store(state: T): T { + let o: any = getOwner(); + if (!o || !on()) return state; + while (o._transparent && o._parent) o = o._parent; + if (o.id == null) return state; + const n = count(o); + counted(o, n + 1); + // A store handed to `createStore` again keeps its own view (the client + // names that target twice and attributes none of its writes). + if (!viewable(state) || (state as any)[RAW] !== undefined) return state; + return storeView(state, materializeId(o) + "~" + n, new WeakMap()); + }, signal(read: () => T): () => T { let o: any = getOwner(); if (!o || !on()) return read; diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 811d49823..28fb7d8ca 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -171,6 +171,10 @@ export function setReadWatch(_watch: ((node: object) => void) | null): void {} export function signalName(_node: object | undefined): string | undefined { return undefined; } +/** @internal Dependency edges: client-only. */ +export function storeName(_proxy: object | undefined): string | undefined { + return undefined; +} /** @internal */ export { ssrHandleError, ssrScope } from "./hydration.js"; // After the runtime modules above, so this import adds no edge to the module diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 46a666b3c..4e6dd6f1e 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1242,6 +1242,8 @@ export interface EdgeHooks { on(): boolean; /** A plain signal created under the current owner: its named read. */ signal(read: () => T): () => T; + /** A plain store root created under the current owner: a view whose every read reports its name. */ + store(state: T): T; /** Run a memo's compute (keyed by its owner), keeping what it read. Only when `on()`. */ run(owner: object, fn: () => T): T; /** A memo was read: report what its last compute read. */ @@ -2603,7 +2605,7 @@ export function createStore( return [store as Store, storeSetter(store as T)]; } const state = first as T; - return [state as Store, storeSetter(state)]; + return [(edgeHooks !== null ? edgeHooks.store(state) : state) as Store, storeSetter(state)]; } /** @@ -2646,7 +2648,12 @@ export function createOptimisticStore( // masks that revert at settle, and server output is settled state. The // setter never invokes its function — a draft mutation here would be a // literal (permanent) mutation, the opposite of optimistic. - const [store] = (createStore as Function)(first, second, third) as [Store, StoreSetter]; + // An object seed is served as is: not named for dependency edges (the + // client names no optimistic store; a write to one mounts everything). + const store = + typeof first === "function" + ? ((createStore as Function)(first, second, third) as [Store, StoreSetter])[0] + : (first as Store); return [store, (() => warnServerWrite("optimistic")) as StoreSetter]; } @@ -2844,7 +2851,9 @@ export function createProjection( return proxy; }; // Seedless: the root starts as an empty record (the client's base too). - const [state] = createStore((seed || {}) as NoFn); + // The seed itself, not a `createStore` over it: a projection's state is + // not a plain store (no dependency-edge name; the client names none). + const state = (seed || {}) as T; if (options?.ssrSource === "client") { // seedLoadingValue = declared commit #0: the seed renders. Bare = the diff --git a/packages/solid/test/server/signal-identities.spec.ts b/packages/solid/test/server/signal-identities.spec.ts index 27aa878a8..92baee74c 100644 --- a/packages/solid/test/server/signal-identities.spec.ts +++ b/packages/solid/test/server/signal-identities.spec.ts @@ -12,7 +12,9 @@ import { createMemo, createOwner, createRoot, + createProjection, createSignal, + createStore, edgeHooks, mapArray, resetOwnerForRerun, @@ -118,6 +120,53 @@ describe("server plain-signal identities", () => { expect(passes[1]).toEqual(first); }); + test("a plain store is named by its root: any read of it, at any depth, reports that name", () => { + edgesRender(); + const r = createRoot( + () => { + const [a] = createSignal(0); + const [store] = createStore({ user: { name: "a" }, tags: ["x", "y"], n: 1 }); + const proj = createProjection<{ v: number }>(() => {}, { v: 0 }); + const [b] = createSignal(0); + return { + a: readsOf(() => a()), + b: readsOf(() => b()), + leaf: readsOf(() => store.user.name), + keys: readsOf(() => Object.keys(store.user)), + iterate: readsOf(() => store.tags.map(t => t)), + has: readsOf(() => "n" in store), + proj: readsOf(() => proj.v), + same: store.user === store.user + }; + }, + { id: "st" } + ); + // Signals around it keep their counts: the store took `st~1`, the + // projection none. + expect(r.a).toEqual(["st~0"]); + expect(r.b).toEqual(["st~2"]); + for (const reads of [r.leaf, r.keys, r.iterate, r.has]) expect(reads).toEqual(["st~1"]); + expect(r.proj).toEqual([]); + expect(r.same).toBe(true); + }); + + test("a store held inside another store makes the holder wide", () => { + edgesRender(); + const ctx = sharedConfig.context as any; + const r = createRoot( + () => { + const [inner] = createStore({ n: 0 }); + const [outer] = createStore({ inner }); + return readsOf(() => outer.inner.n); + }, + { id: "w" } + ); + // The held store reads as itself; the holder is wide (a setter call on + // it can write the held store, whose own readers its name misses). + expect(r.sort()).toEqual(["w~0", "w~1"]); + expect([...ctx.edgeWide.s]).toEqual(["w~1"]); + }); + test("the hooks are installed only while an edges render is in flight", () => { const a = beginEdgesRender(); const b = beginEdgesRender(); diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 6c1e7d690..d82391937 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -69,7 +69,8 @@ import { setSignalIdentities, setWriteHook, signalName, - sharedConfig + sharedConfig, + storeName } from "solid-js/internal"; // The container tier (DR-2 case 3): server projections cross the border as @@ -1603,17 +1604,20 @@ function mountAllDeferred() { } // Dependency edges. The server ships, per document frame, which occurrences -// read which plain signals they did not create (`sc:deps::` -// batches, `sid -> "card#0-399 cart#0"`; `*`: wide signals, whose readers it -// could not track; `!`: occurrences with reads it could not see). A write to -// a named signal mounts only the unmounted occurrences that read it — each -// with the occurrences enclosing it first, as an event would — before the -// write applies; a named signal no record names mounts nothing (no -// unmounted occurrence read it). Everything the records cannot vouch for -// mounts everything left: a write with no name (a store, a derived or -// optimistic signal, a signal of an owner without an id), a wide signal, a -// write while the document still streams (records may not have arrived), or -// a deferred frame under a root that shipped no records. +// read which plain signals and plain stores they did not create +// (`sc:deps::` batches, `sid -> "card#0-399 cart#0"`; `*`: wide +// names, whose readers it could not track; `!`: occurrences with reads it +// could not see). A store goes by its root's name: any read of it is a read +// of that name, any setter call on it a write. A write to a name mounts only +// the unmounted occurrences that read it — each with the occurrences +// enclosing it first, as an event would — before the write applies; a name +// no record lists mounts nothing (no unmounted occurrence read it). +// Everything the records cannot vouch for mounts everything left: a write +// with no name (a projection or optimistic store, a store named twice, a +// derived or optimistic signal, a signal or store of an owner without an +// id), a wide name, a write while the document still streams (records may +// not have arrived), or a deferred frame under a root that shipped no +// records. const edgeRoots = new Map(); type EdgeIndex = { readers: Map; @@ -1688,7 +1692,8 @@ function edgesCover(idx: EdgeIndex): boolean { } function mountForWrite(node?: object) { if (mounting || (sharedConfig as any).hydrating) return; - const sid = signalName(node); + // A plain signal by its own name; a store setter by its store's. + const sid = signalName(node) ?? storeName(node); if (sid === undefined || document.readyState === "loading") return mountAllDeferred(); const idx = (edgeIndex ||= buildEdgeIndex()); if (!idx || idx.wide.has(sid) || !edgesCover(idx)) return mountAllDeferred(); diff --git a/packages/web/test/harness/__artifacts__/frame-dependency-edges.json b/packages/web/test/harness/__artifacts__/frame-dependency-edges.json index 8ebd691a2..9c471b6fa 100644 --- a/packages/web/test/harness/__artifacts__/frame-dependency-edges.json +++ b/packages/web/test/harness/__artifacts__/frame-dependency-edges.json @@ -1,5 +1,5 @@ { "name": "frame-dependency-edges", - "shell": "
", + "shell": "
", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/frame-dependency-edges.tsx b/packages/web/test/harness/frame-dependency-edges.tsx index 958e5e06b..85755b2ac 100644 --- a/packages/web/test/harness/frame-dependency-edges.tsx +++ b/packages/web/test/harness/frame-dependency-edges.tsx @@ -4,9 +4,12 @@ * Dependency edges under deferred hydration. The route shell owns shared * state and provides it through context; the server component places slot * fills that read some of it: - * - four `item` fills read `currency` and their own click counter; + * - four `item` fills read `currency` and their own state: a click + * counter and a card-local store; * - `cart` reads `cart`; - * - `store` reads a store (writes to a store carry no signal name); + * - `store` reads a plain store (named by its root, like a signal); + * - `dstore` reads a derived store (a projection: its writes carry no + * name); * - `wide` reads a projection derived from `wideSource` (a projection's * readers are not tracked, so `wideSource` is wide); * - `derived` reads a writable derived signal (no name either); @@ -14,6 +17,9 @@ * sees), so it mounts on every named write. * Every fill notes its mount in `mounted`. * + * `loose` is a store created outside any owner: it has no name, so a write + * to it mounts everything. + * * `drift` (client only) creates one extra plain signal in the shell ahead * of the shared ones, so the client names them differently from the server. * @@ -45,6 +51,8 @@ export type Shop = { setCurrency: Setter; store: { n: number }; setStore: (fn: (s: { n: number }) => void) => void; + dstore: { v: number }; + setDStore: (fn: (s: { v: number }) => void) => void; setWideSource: Setter; wide: { v: number }; derived: Accessor; @@ -52,6 +60,9 @@ export type Shop = { }; export const ShopCtx = createContext(); +/** A store created outside any owner: unnamed. */ +export const [loose, setLoose] = createStore({ n: 0 }); + /** Each fill mount, by name; the last shell's state, for the test's writes. */ export const mounted: string[] = []; export let shop: Shop; @@ -62,6 +73,7 @@ export function Shell(props: { drift?: boolean; children: JSX.Element }) { const [currency, setCurrency] = createSignal("USD"); const [wideSource, setWideSource] = createSignal(1); const [store, setStore] = createStore({ n: 0 }); + const [dstore, setDStore] = createStore<{ v: number }>(() => {}, { v: 0 }); const wide = createProjection<{ v: number }>( d => { d.v = wideSource(); @@ -76,6 +88,8 @@ export function Shell(props: { drift?: boolean; children: JSX.Element }) { setCurrency, store, setStore, + dstore, + setDStore, setWideSource, wide, derived, @@ -87,10 +101,15 @@ export function Shell(props: { drift?: boolean; children: JSX.Element }) { export function Item(props: { id: string }) { const s = useContext(ShopCtx)!; const [clicks, setClicks] = createSignal(0); + const [own, setOwn] = createStore({ n: 0 }); mounted.push(`item:${untrack(() => props.id)}`); + const click = () => { + setClicks(c => c + 1); + setOwn(o => void (o.n += 10)); + }; return ( - ); } @@ -104,6 +123,11 @@ export function StoreView() { mounted.push("store"); return

store:{s.store.n}

; } +export function DerivedStoreView() { + const s = useContext(ShopCtx)!; + mounted.push("dstore"); + return

dstore:{s.dstore.v}

; +} export function WideView() { const s = useContext(ShopCtx)!; mounted.push("wide"); @@ -132,6 +156,7 @@ export function makeApp(source: () => any, options: { drift?: boolean } = {}) { item={(p: any) => } cart={() => } store={() => } + dstore={() => } wide={() => } derived={() => } late={() => } @@ -144,7 +169,15 @@ export function makeApp(source: () => any, options: { drift?: boolean } = {}) { /** The server component: the four items, then the cart sidebar's widgets. */ export function makePageComponent() { - return (props: { item: any; cart: any; store: any; wide: any; derived: any; late: any }) => ( + return (props: { + item: any; + cart: any; + store: any; + dstore: any; + wide: any; + derived: any; + late: any; + }) => (
    {ITEMS.map(id => ( @@ -156,6 +189,7 @@ export function makePageComponent() {