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": "
note v1
"
+ "rest": "
note v1
"
}
\ 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": "
room v1
"
}
\ 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": "
room v1
"
}
\ 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": "
room v1
"
}
\ 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": "
note v1
"
+ "rest": "
note v1
"
}
\ 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": "
note v1
"
+ "rest": "
note v1
"
}
\ 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": "42 tokens · 7 tok/s"
+ "shell": "
¶ 1…
",
+ "rest": "42 tokens · 7 tok/s"
}
\ 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": "42 tokens · 7 tok/s"
+ "shell": "
¶ 1…
",
+ "rest": "42 tokens · 7 tok/s"
}
\ 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
;
+}
+
+/** 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