diff --git a/.changeset/frames-dependency-edges.md b/.changeset/frames-dependency-edges.md new file mode 100644 index 0000000000..2813c150ee --- /dev/null +++ b/.changeset/frames-dependency-edges.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +"@solidjs/web": patch +--- + +Frames: a signal or store write during deferred hydration mounts only the slot occurrences the server recorded as its readers, then applies, instead of mounting every pending occurrence first. The server names plain signals (`~`) and plain stores (by their root) in server-component renders and emits `sc:deps` records per frame; writes it cannot vouch for (unnamed, derived, optimistic, projection or wide sources) still mount everything first. Apps without server components keep their sizes and code paths. diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 7f52190034..5e610220f1 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1688,6 +1688,7 @@ export function pullComputed(owner: Computed, c: Computed): void { } export function read(el: Signal | Computed): T { + if (__DEV__) DEV.hooks.onRead?.(el); if (__DEV__ && asyncTailFlights !== 0 && !tracking && untrackDepth === 0) checkPostAwaitRead( el, @@ -2029,20 +2030,21 @@ export function captureWriteSnapshot(el: Signal | Computed, current: T) } /** - * A callback run before every signal or store write lands, while set. The + * A callback run before every signal or store write lands, while set, with + * the written signal or the store proxy whose setter runs. The * server-components client sets it while slot occurrences are left * unmounted after hydration (deferred hydration): a write may be one an * unmounted component would have observed, so the client mounts what is * left before the write applies, and clears the hook. `null` otherwise. */ -export let writeHook: (() => void) | null = null; +export let writeHook: ((node?: object) => void) | null = null; /** @internal */ -export function setWriteHook(hook: (() => void) | null): void { +export function setWriteHook(hook: ((node?: object) => void) | null): void { writeHook = hook; } export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T)): T { - if (writeHook !== null) writeHook(); + 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 846debf64a..cae95e5c53 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 0000000000..291aa77363 --- /dev/null +++ b/packages/signals/src/core/identities.ts @@ -0,0 +1,64 @@ +import { CONFIG_TRANSPARENT } from "./constants.js"; +import { DEV } from "./dev.js"; +import { getOwner } from "./owner.js"; +import type { Owner } from "./types.js"; + +// Deferred hydration, dependency edges: plain-signal identities. +// While on (`setSignalIdentities`; the server-components client turns it on +// before hydrating), a plain signal created under an id-bearing owner is +// named `~`, n counting the plain signals (and plain store +// roots: `createStore` over an object, store/edges.ts) created under that +// owner in its current pass (transparent owners defer to the nearest +// id-bearing one, as child ids do). The server names the same signal the +// same way and restarts the count on every re-run, so an edge it shipped (a +// slot fill read it) matches the client node; the write hook receives the +// node and looks its name up (`signalName`). The count restarts when the owner's pass +// generation (`_depGen`, bumped by every recompute) moves on. Only the +// frames client imports this module, so nothing here ships without it. +// Names live in a side table, not on the node: frames reads them across the +// package boundary, where a mangled field would not survive. +let counts: WeakMap | null = null; +const names = new WeakMap(); + +/** @internal The identity of a plain signal named while identities were on. */ +export function signalName(node: object | undefined): string | undefined { + return node === undefined ? undefined : names.get(node); +} + +function identify(node: object): void { + let o = getOwner(); + if (!o) return; + while (o._config & CONFIG_TRANSPARENT && o._parent) o = o._parent; + if (o.id == null) return; + const gen = (o as any)._depGen as number | undefined; + let c = counts!.get(o); + if (c === undefined || c[0] !== gen) counts!.set(o, (c = [gen, 0])); + const sid = o.id + "~" + c[1]++; + // A store root named twice (one object made into two stores, or a store + // handed to `createStore`) is one target under two server names: its + // writes cannot be attributed (an empty name). + names.set(node, names.has(node) ? "" : sid); +} + +/** @internal Name plain signals from now on (`on`), or stop. */ +export function setSignalIdentities(on: boolean): void { + counts = on ? counts || new WeakMap() : null; + signalHook = on ? identify : null; +} + +/** + * @internal Called with every plain signal `createSignal` makes, and every + * plain store root `createStore` makes, while set (the namer above). Never + * set unless `setSignalIdentities` is imported, so a bundle without it keeps + * only a null check. + */ +export let signalHook: ((node: object) => void) | null = null; + +/** + * @internal Dev only: `watch` is called with every node read while set (the + * dev hook `onRead`) — the frames client's identity-drift check watches the + * named signals a deferred occurrence reads as it mounts. A no-op in prod. + */ +export function setReadWatch(watch: ((node: object) => void) | null): void { + if (__DEV__) DEV.hooks.onRead = watch || undefined; +} diff --git a/packages/signals/src/core/index.ts b/packages/signals/src/core/index.ts index 9c55d17279..b2060560c0 100644 --- a/packages/signals/src/core/index.ts +++ b/packages/signals/src/core/index.ts @@ -36,6 +36,7 @@ export { cleanup, peekNextChildId } from "./owner.js"; +export { setSignalIdentities, signalName, signalHook, setReadWatch } from "./identities.js"; export { createContext, getContext, diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index a2aec9778b..082b8b46f8 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -39,7 +39,10 @@ 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"; +/** @internal Plain-signal identities and the dev read watch (dependency edges; the server-components client). */ +export { setWriteHook, setSignalIdentities, setReadWatch, signalName } from "./core/index.js"; +/** @internal Store dependency edges: the name a store proxy's writes go by (the server-components client). */ +export { storeName } from "./store/edges.js"; import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js"; /** * Observe tier (diagnostics channel, attribution hook slot + interaction diff --git a/packages/signals/src/signals.ts b/packages/signals/src/signals.ts index c912101886..8dbb3da1e8 100644 --- a/packages/signals/src/signals.ts +++ b/packages/signals/src/signals.ts @@ -20,6 +20,7 @@ import { runWithOwner, setMemo, setSignal, + signalHook, signal, trackedEffect, untrack, @@ -416,6 +417,7 @@ export function createSignal( } const node = signal(first as any, second as SignalOptions); if (__OBSERVE__) registerGraph(node, getOwner()); + if (signalHook !== null) signalHook(node); return [accessor(node), setSignal.bind(null, node as any) as Setter]; } diff --git a/packages/signals/src/store/edges.ts b/packages/signals/src/store/edges.ts new file mode 100644 index 0000000000..7ac3d05dc0 --- /dev/null +++ b/packages/signals/src/store/edges.ts @@ -0,0 +1,38 @@ +/** + * Deferred hydration, dependency edges for stores. Only the frames client + * imports this module, so nothing here ships without it. + * + * A plain store root (`createStore` over an object) is named like a plain + * signal (core/identities.ts: `~`, on the same count), and the + * whole store goes by that one name: the server records it for any read of + * the store, at any depth, and a setter call on the store — on its root or + * on a proxy nested in it — is a write to it. Its readers mount at the + * setter's entry, before the draft runs, as a plain signal's do. + */ +import { CONFIG_SLOT_NODE } from "../core/constants.js"; +import { signalName } from "../core/identities.js"; +import type { StoreTarget } from "./target.js"; +import { $TARGET } from "./types.js"; + +/** + * @internal The name of the store `x` belongs to — `x` a store proxy (a + * setter's entry) or one of its property nodes (the setter notifying it) — + * or undefined when it has none or it cannot be placed: a projection or + * optimistic family, a store chained over another store's proxy, a root + * named twice, or a proxy reached through a store other than the one it + * was named as the root of. + */ +export function storeName(x: object | undefined): string | undefined { + if (x === undefined) return undefined; + let t: StoreTarget | undefined = + (x as any)._config & CONFIG_SLOT_NODE ? (x as any)._host : (x as any)[$TARGET]; + if (t === undefined) return undefined; + // Up to the root; a target on the way named as a root of its own is one + // object in two stores. + while (t.u !== null) { + if (t.fam !== null || t.ch || signalName(t) !== undefined) return undefined; + t = t.u; + } + if (t.fam !== null || t.ch) return undefined; + return signalName(t) || undefined; +} diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 64edbae5de..2b374efd11 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -73,6 +73,7 @@ import { registerGraph, warnStrictReadUntracked } from "../core/dev.js"; +import { signalHook } from "../core/identities.js"; import { setSlotUnobserved } from "../core/graph.js"; import { getObserver, getOwner } from "../core/owner.js"; import { insertIntoHeap } from "../core/heap.js"; @@ -2317,7 +2318,7 @@ function devGuardStoreSetterResult(result: unknown): void { } export function storeSetter(proxy: T, fn: (draft: T) => T | void, guard = true): void { - if (writeHook !== null) writeHook(); + if (writeHook !== null) writeHook(proxy as object); if (__DEV__ && guard) devGuardStoreSetterWrite(); const target: StoreTarget = (proxy as any)[$TARGET]; const prevScopes = writeScopes; @@ -2389,6 +2390,8 @@ export function createStore>( throw new Error("createStore({ shallow }): value is already a store proxy"); } const proxy = wrap(initialValue); + // Dependency edges (store/edges.ts): the root is named like a plain signal. + if (signalHook !== null) signalHook((proxy as any)[$TARGET]); if (shallow) { ((proxy as any)[$TARGET] as StoreTarget).s = true; markRawIngest(initialValue); diff --git a/packages/signals/tests/attribution-interactions.test.ts b/packages/signals/tests/attribution-interactions.test.ts index da9844ce83..50eae6656d 100644 --- a/packages/signals/tests/attribution-interactions.test.ts +++ b/packages/signals/tests/attribution-interactions.test.ts @@ -504,7 +504,13 @@ describe("a handler that returns a promise", () => { await wait(20); }); await until(() => interactionLog().length === 1, "the record to settle"); - expect(interactionLog()[0].continuationMs).toBeGreaterThanOrEqual(15); + // The 20 ms timer starts inside the handler, before it returns, and + // `continuationMs` counts from the return: a slow return (coverage + // instrumentation on a loaded runner) shortens it. Measured from the + // frame's open, the record waited the whole timer. + const record = interactionLog()[0]; + expect(record.continuationMs).toBeGreaterThan(0); + expect(record.handlerMs + record.continuationMs!).toBeGreaterThanOrEqual(15); expect(findings).toHaveLength(0); }); }); diff --git a/packages/signals/tests/signal-identities.test.ts b/packages/signals/tests/signal-identities.test.ts new file mode 100644 index 0000000000..66fed664a0 --- /dev/null +++ b/packages/signals/tests/signal-identities.test.ts @@ -0,0 +1,168 @@ +// 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. A plain store +// root is named on the same count, and every write to the store goes by its +// root's name. The pre-write hook receives the named node (or the store +// proxy whose setter runs, then the store's property nodes as it +// notifies), and the dev read watch sees it on every read. +import { afterEach, describe, expect, test } from "vitest"; +import { setReadWatch, setSignalIdentities, setWriteHook, signalName } from "../src/core/index.js"; +import { + createMemo, + createOptimisticStore, + createProjection, + createRoot, + createSignal, + createStore, + flush, + storeName +} from "../src/index.js"; + +afterEach(() => { + setWriteHook(null); + setReadWatch(null); + setSignalIdentities(false); +}); + +function hookedWrites() { + const writes: (string | undefined)[] = []; + setWriteHook(node => writes.push(signalName(node))); + return writes; +} +/** Every write-hook call's name, a store's included (deduped in order). */ +function hookedStoreWrites() { + const writes: (string | undefined)[] = []; + setWriteHook(node => { + const sid = signalName(node) ?? storeName(node); + if (writes[writes.length - 1] !== sid) writes.push(sid); + }); + return writes; +} + +describe("plain-signal identities", () => { + test("named per id-bearing owner, in creation order; the hook receives the named node", () => { + 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); + // The derived signal carries no name; the store is `r~2` (the hook gets + // its proxy, then its property node: `storeName` names both). + expect(writes).toEqual(["r~0", "r~1", undefined]); + const stores = hookedStoreWrites(); + setS(s => void (s.n = 1)); + expect(stores).toEqual(["r~2"]); + }); + + test("a store goes by its root's name, from its root or any proxy nested in it", () => { + setSignalIdentities(true); + const [store, setStore, before, after] = createRoot( + () => { + const before = createSignal(0)[1]; + const [store, setStore] = createStore({ user: { name: "a" }, tags: ["x"] }); + const after = createSignal(0)[1]; + return [store, setStore, before, after] as const; + }, + { id: "s" } + ); + expect(storeName(store)).toBe("s~1"); + expect(storeName(store.user)).toBe("s~1"); + expect(storeName(store.tags)).toBe("s~1"); + const writes = hookedStoreWrites(); + before(1); + setStore(s => void (s.user.name = "b")); + after(1); + expect(writes).toEqual(["s~0", "s~1", "s~2"]); + }); + + test("stores it cannot place have no name", () => { + setSignalIdentities(true); + const shared = { n: 0 }; + const [outside] = createStore({ n: 0 }); + const r = createRoot( + () => { + const [twice1] = createStore(shared); + const [twice2] = createStore(shared); + const proj = createProjection<{ v: number }>(() => {}, { v: 0 }); + const [opt] = createOptimisticStore({ n: 0 }); + const [plain] = createStore({ n: 0 }); + return { twice1, twice2, proj, opt, plain }; + }, + { id: "u" } + ); + // Created outside any owner; one object made into two stores (one target + // under two names); a projection; an optimistic store. + expect(storeName(outside)).toBeUndefined(); + expect(storeName(r.twice1)).toBeUndefined(); + expect(storeName(r.twice2)).toBeUndefined(); + expect(storeName(r.proj)).toBeUndefined(); + expect(storeName(r.opt)).toBeUndefined(); + // The count still advanced for the two createStore calls. + expect(storeName(r.plain)).toBe("u~2"); + }); + + test("the count restarts when the owner re-runs", () => { + 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(node => { + const sid = signalName(node); + if (sid !== undefined) 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 d4a3d384bc..91e6c5adf8 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -184,6 +184,20 @@ export function creationStamp(): number { } /** @internal A callback run before every signal or store write lands (deferred hydration). */ export { setWriteHook } from "@solidjs/signals"; +/** @internal Dependency edges: name plain signals so a write reports which one; dev read watch. */ +export { setSignalIdentities, setReadWatch, signalName, storeName } from "@solidjs/signals"; +/** @internal Dependency edges: server-only (an edges render names plain signals). Client: no-op. */ +export function beginEdgesRender(): () => void { + return () => {}; +} +/** @internal Dependency edges: server-only read tracking; the client runs `fn`. */ +export function trackFillReads(fn: () => T): { + result: T; + reads: Set | null; + untracked: boolean; +} { + return { result: fn(), reads: null, untracked: false }; +} /** @internal */ export function inServerComponentScope(): boolean { return false; diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index 8c1940e1e2..9a14d48a5f 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -129,7 +129,34 @@ 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: ((node?: object) => void) | null) => void = + core.setWriteHook as any; +/** Dependency edges. Client: name plain signals (`~`) from now on. Server: no-op. */ +export const setSignalIdentities: (on: boolean) => void = core.setSignalIdentities; +/** + * Dependency edges. Server: a render that names and tracks plain signals + * starts; call the returned function when it ends. Client: no-op. + */ +export const beginEdgesRender: () => () => void = core.beginEdgesRender; +/** Dependency edges, dev only. Client: watch every node read. Server: no-op. */ +export const setReadWatch: (watch: ((node: object) => void) | null) => void = core.setReadWatch; +/** Dependency edges. Client: a plain signal's identity, if it was named. Server: `undefined`. */ +export const signalName: (node: object | undefined) => string | undefined = core.signalName; +/** + * Dependency edges. Client: the name a store proxy's writes go by (its + * root's, when the root was named and can be placed). Server: `undefined`. + */ +export const storeName: (proxy: object | undefined) => string | undefined = core.storeName; +/** + * Dependency edges. Server: run a slot fill, returning the plain-signal + * identities it read (through memos too) and whether it ran a computation + * whose reads the server cannot see. Client: runs it. + */ +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/edges.ts b/packages/solid/src/server/edges.ts new file mode 100644 index 0000000000..77bd0e8d56 --- /dev/null +++ b/packages/solid/src/server/edges.ts @@ -0,0 +1,251 @@ +// Deferred hydration, dependency edges: plain-signal and plain-store +// identities and read tracking on the server. The render paths in signals.ts hold one +// null-checked slot, filled only while an edges render is in flight +// (`beginEdgesRender`, renderToStream with the server-components codec); the +// hooks act only inside such a render's context (`on`), allocating nothing +// for any other. +// +// A plain signal created under an id-bearing owner is named `~` +// (n counts plain signals under that owner since its child counter last +// reset), as the client's signals core names it. A read reports the name to +// the current sink: a slot fill's (`trackFillReads`), a memo's — a memo read +// re-reports what its compute read, so an edge through a memo lands on its +// sources — or the request's WIDE sink, for reads whose readers are not +// tracked (a projection's compute): a write to a wide signal mounts +// everything left. +import { formatChildId, getOwner, materializeId, setEdgeHooks, type EdgeHooks } from "./signals.js"; +import { sharedConfig } from "./shared.js"; + +// Per render: only a render whose context has `edges` set (renderToStream, +// with the server-components codec among its plugins) names or tracks. +function on(): boolean { + const ctx = sharedConfig.context as any; + return !!ctx && ctx.edges === true; +} + +type ReadSink = { s: Set | null; wide?: true; untracked?: true }; +let readSink: ReadSink | null = null; +// The innermost slot fill being tracked (`trackFillReads`), for computations +// whose reads the server cannot see: a client-source memo or effect, a +// tracked effect (its compute never runs here). The fill's edges are then +// incomplete, and it mounts on every named write. +let fillSink: ReadSink | null = null; + +// Plain signals created under each id-bearing owner, beside the id they +// were counted under. An owner's entry is dropped with its child counter: by +// a cleanup on the owner (dispose, retry reset, rerun) or by `reset` (a +// reused row owner's pass); a count taken under another id is a previous +// row's (a reused row owner moves its id on per row), so it reads as 0. +const counts = new WeakMap(); +// The id `materializeId` would give, without materializing it. +function idOf(owner: any): unknown { + return owner._scopeSlot >= 0 ? formatChildId(owner.id, owner._scopeSlot) : owner.id; +} +function count(owner: any): number { + const c = counts.get(owner); + return c !== undefined && c[0] === idOf(owner) ? c[1] : 0; +} +function counted(owner: any, n: number): void { + if (!counts.has(owner)) { + const drop = () => void counts.delete(owner); + const d = owner._disposal; + owner._disposal = !d ? drop : Array.isArray(d) ? (d.push(drop), d) : [d, drop]; + } + counts.set(owner, [idOf(owner), n]); +} + +// What each memo's last compute read (keyed by its owner), re-reported when +// the memo is read: an edge through a memo lands on its sources. +const memoReads = new WeakMap>(); +// Each id scope's resume point (`ssrScope`'s retry pull, the boundary +// resolve scope): the plain-signal count a resumed pass continues from. +const scopes = new WeakMap(); + +function reportReads(reads: Set): void { + if (readSink === null) return; + const s = (readSink.s ||= new Set()); + for (const sid of reads) s.add(sid); +} + +// Plain stores: a root is named like a plain signal (on the same count) and +// read through a view that reports that one name for any read of it, at any +// depth (a property, `in`, its keys); the client writes the whole store by +// that name. Objects under it are viewed too. A store held inside another +// is returned as its own view (its reads are its own); the holder becomes +// wide, since a setter call on the holder can write the held store, whose +// direct readers the holder's name does not reach. +const RAW = Symbol(); +function viewable(v: any): boolean { + if (v === null || typeof v !== "object" || Object.isFrozen(v)) return false; + const p = Object.getPrototypeOf(v); + return p === Object.prototype || p === null || Array.isArray(v); +} +function storeView(raw: T, sid: string, views: WeakMap): T { + let view = views.get(raw) as T | undefined; + if (view !== undefined) return view; + const read = () => { + if (readSink !== null) (readSink.s ||= new Set()).add(sid); + }; + view = new Proxy(raw, { + get(t, k, r) { + if (k === RAW) return sid; + const v = Reflect.get(t, k, r); + if (typeof k === "symbol" || typeof v === "function") return v; + read(); + if (!viewable(v)) return v; + if ((v as any)[RAW] !== undefined) { + const ctx = sharedConfig.context as any; + if (ctx) ((ctx.edgeWide ||= { s: null, wide: true }).s ||= new Set()).add(sid); + return v; + } + // A non-configurable read-only property must read as itself. + const d = Reflect.getOwnPropertyDescriptor(t, k); + return d && !d.configurable && !d.writable ? v : storeView(v as object, sid, views); + }, + has(t, k) { + read(); + return Reflect.has(t, k); + }, + ownKeys(t) { + read(); + return Reflect.ownKeys(t); + }, + getOwnPropertyDescriptor(t, k) { + read(); + return Reflect.getOwnPropertyDescriptor(t, k); + } + }); + views.set(raw, view); + return view; +} + +const hooks: EdgeHooks = { + on, + store(state: T): T { + let o: any = getOwner(); + if (!o || !on()) return state; + while (o._transparent && o._parent) o = o._parent; + if (o.id == null) return state; + const n = count(o); + counted(o, n + 1); + // A store handed to `createStore` again keeps its own view (the client + // names that target twice and attributes none of its writes). + if (!viewable(state) || (state as any)[RAW] !== undefined) return state; + return storeView(state, materializeId(o) + "~" + n, new WeakMap()); + }, + signal(read: () => T): () => T { + let o: any = getOwner(); + if (!o || !on()) return read; + while (o._transparent && o._parent) o = o._parent; + if (o.id == null) return read; + const n = count(o); + counted(o, n + 1); + const sid = materializeId(o) + "~" + n; + return () => { + if (readSink !== null) (readSink.s ||= new Set()).add(sid); + return read(); + }; + }, + run(owner: object, fn: () => T): T { + const prev = readSink; + const sink: ReadSink = (readSink = { s: null }); + try { + return fn(); + } finally { + readSink = prev; + if (sink.s !== null) memoReads.set(owner, sink.s); + else memoReads.delete(owner); + } + }, + report(owner: object) { + if (readSink === null) return; + const reads = memoReads.get(owner); + if (reads !== undefined) reportReads(reads); + }, + projection(ctx: any, fn: F): F { + if (!on()) return fn; + return ((...args: unknown[]) => { + const prev = readSink; + readSink = ctx.edgeWide ||= { s: null, wide: true }; + try { + return fn(...args); + } finally { + readSink = prev; + } + }) as unknown as F; + }, + untracked() { + if (fillSink !== null) fillSink.untracked = true; + }, + enter(key: object, owner: object, resumed: boolean): number { + if (!on()) return 0; + const prev = count(owner); + counted(owner, resumed ? scopes.get(key)?.resume || 0 : 0); + return prev; + }, + mark(key: object, owner: object) { + if (!on()) return; + let st = scopes.get(key); + if (st === undefined) scopes.set(key, (st = { mark: 0, resume: 0 })); + st.mark = count(owner); + }, + leave(key: object, owner: object, prev: number, keep: 0 | 1 | 2) { + if (!on()) return; + if (keep !== 0) { + let st = scopes.get(key); + if (st === undefined) scopes.set(key, (st = { mark: 0, resume: 0 })); + st.resume = keep === 1 ? st.mark : count(owner); + } + counted(owner, prev); + }, + reset(owner: object) { + counts.delete(owner); + } +}; + +// Edges renders in flight. The hooks are installed only while one is: a +// process that never starts one (or has none running) keeps the plain paths +// with nothing behind the slot. Interleaved async renders still check their +// own context (`on`). +let active = 0; +/** @internal Edges renders in flight (tests). */ +export function edgesRenders(): number { + return active; +} +/** + * @internal An edges render starts (renderToStream with the server-components + * codec): install the hooks. Call the returned function once when it ends. + */ +export function beginEdgesRender(): () => void { + if (active++ === 0) setEdgeHooks(hooks); + let ended = false; + return () => { + if (ended) return; + ended = true; + if (--active === 0) setEdgeHooks(null); + }; +} + +/** + * @internal Run a slot fill, returning its result, the plain-signal + * identities it read, and whether it ran a computation whose reads the + * server cannot see (`untracked`). + */ +export function trackFillReads(fn: () => T): { + result: T; + reads: Set | null; + untracked: boolean; +} { + if (!on()) return { result: fn(), reads: null, untracked: false }; + const prev = readSink; + const prevFill = fillSink; + const sink: ReadSink = { s: null }; + readSink = fillSink = sink; + try { + const result = fn(); + return { result, reads: sink.s, untracked: !!sink.untracked }; + } finally { + readSink = prev; + fillSink = prevFill; + } +} diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 4b62b2738a..f605daa11f 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -14,7 +14,8 @@ import { throwerOf, ownerId, onCleanup, - inServerComponentScope + inServerComponentScope, + edgeHooks } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -520,10 +521,13 @@ function ssrLoadingBoundary( const prevCount = (o as any)._childCount; (o as any).id = flattenId; (o as any)._childCount = resolveCount; + // Dependency edges: the scope's plain-signal count continues too. + const prevSig = edgeHooks !== null ? edgeHooks.enter(resolveIn, o, true) : 0; try { return run(); } finally { resolveCount = (o as any)._childCount; + if (edgeHooks !== null) edgeHooks.leave(resolveIn, o, prevSig, 2); (o as any).id = contentId; (o as any)._childCount = prevCount; } diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 0fe82a2e3f..28fb7d8cac 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -159,8 +159,22 @@ export { reportServerError } from "./signals.js"; export type { ServerErrorSite, ServerErrorHook } from "./signals.js"; +/** @internal Dependency edges (the frames server installs them). */ +export { trackFillReads, beginEdgesRender } from "./edges.js"; /** @internal Client-only: the server has no writes to observe. */ -export function setWriteHook(_hook: (() => void) | null): void {} +export function setWriteHook(_hook: ((node?: object) => void) | null): void {} +/** @internal Dependency edges: client-only (the server names through `beginEdgesRender`). */ +export function setSignalIdentities(_on: boolean): void {} +/** @internal Dependency edges: client-only. */ +export function setReadWatch(_watch: ((node: object) => void) | null): void {} +/** @internal Dependency edges: client-only. */ +export function signalName(_node: object | undefined): string | undefined { + return undefined; +} +/** @internal Dependency edges: client-only. */ +export function storeName(_proxy: object | undefined): string | undefined { + return undefined; +} /** @internal */ export { ssrHandleError, ssrScope } from "./hydration.js"; // After the runtime modules above, so this import adds no edge to the module diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 52f7ca0bad..4e6dd6f1eb 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -192,7 +192,8 @@ let currentOwner: SSROwner | null = null; const OWNER_POOL_MAX = 4096; const ownerPool: SSROwner[] = []; -function formatChildId(prefix: string, id: number): string { +/** @internal */ +export function formatChildId(prefix: string, id: number): string { const num = id.toString(36); const len = num.length - 1; return prefix + (len ? String.fromCharCode(64 + len) : "") + num; @@ -207,7 +208,8 @@ function formatChildId(prefix: string, id: number): string { * never pays for the string. Idempotent; the string is byte-identical * whenever it is built. */ -function materializeId(owner: SSROwner): string | undefined { +/** @internal */ +export function materializeId(owner: SSROwner): string | undefined { if (owner._scopeSlot >= 0) { owner.id = formatChildId(owner.id!, owner._scopeSlot); owner._scopeSlot = -1; @@ -663,6 +665,7 @@ export function ssrScope(fn: () => T): () => unknown { parent.id = prefix; parent._scopeSlot = slot; parent._childCount = resume ? resumeCount : 0; + const prevSig = edgeHooks !== null ? edgeHooks.enter(fn, parent, resume !== undefined) : 0; // The unwrap step in flight, so the one catch below can record the // resume point: a single handler frame on the NotReady unwind (see // `runWithOwner`) instead of an inner catch and an outer finally. @@ -679,13 +682,16 @@ export function ssrScope(fn: () => T): () => unknown { while (typeof v === "function") { next = v as () => unknown; count = parent._childCount; + if (edgeHooks !== null) edgeHooks.mark(fn, parent); v = next(); } } catch (err) { parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; - if (next !== undefined && err instanceof NotReadyError) { + const kept = next !== undefined && err instanceof NotReadyError; + if (edgeHooks !== null) edgeHooks.leave(fn, parent, prevSig, kept ? 1 : 0); + if (kept) { resume = next; resumeCount = count; } @@ -694,6 +700,7 @@ export function ssrScope(fn: () => T): () => unknown { parent.id = prevId; parent._scopeSlot = prevSlot; parent._childCount = prevCount; + if (edgeHooks !== null) edgeHooks.leave(fn, parent, prevSig, 0); return v; }; } @@ -1224,6 +1231,50 @@ function warnServerWrite(category: "signal" | "store" | "optimistic"): void { // projections, and of any memo whose last compute read one of those. let asyncReads = 0; +// --------------------------------------------------------------------------- +// Deferred hydration, dependency edges: plain-signal identities and read +// tracking live in edges.ts, which only the frames server loads. These +// paths hold one null-checked slot, so a render without server components +// carries none of it. +/** @internal The dependency-edge hooks (edges.ts). */ +export interface EdgeHooks { + /** The current render asked for edges. */ + on(): boolean; + /** A plain signal created under the current owner: its named read. */ + signal(read: () => T): () => T; + /** A plain store root created under the current owner: a view whose every read reports its name. */ + store(state: T): T; + /** Run a memo's compute (keyed by its owner), keeping what it read. Only when `on()`. */ + run(owner: object, fn: () => T): T; + /** A memo was read: report what its last compute read. */ + report(owner: object): void; + /** A projection's compute, its reads reported as wide. */ + projection(ctx: object, fn: F): F; + /** The current slot fill ran a computation whose reads the server cannot see. */ + untracked(): void; + /** + * An id scope (`key`) swaps in over `owner`: its plain-signal count starts + * at 0, or where the scope's last kept pass left it (`resumed`). Returns + * the count to restore. + */ + enter(key: object, owner: object, resumed: boolean): number; + /** The scope's resume point: `owner`'s count now. */ + mark(key: object, owner: object): void; + /** The scope swaps out: keep its mark (1) or its count (2) for a resumed pass; restore `prev`. */ + leave(key: object, owner: object, prev: number, keep: 0 | 1 | 2): void; + /** + * A reused row owner starts a pass: its plain-signal count restarts (and + * restarts again whenever its id moves on to the next row). + */ + reset(owner: object): void; +} +/** @internal */ +export let edgeHooks: EdgeHooks | null = null; +/** @internal */ +export function setEdgeHooks(hooks: EdgeHooks | null): void { + edgeHooks = hooks; +} + export function createSignal(): Signal; export function createSignal(value: Exclude, options?: SignalOptions): Signal; // Commit #0 (loadingValue) removes the uninitialized window: never undefined @@ -1260,9 +1311,10 @@ 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). + const read = () => first as T; return [ - () => first as T, + edgeHooks !== null ? edgeHooks.signal(read) : read, v => { warnServerWrite("signal"); return ((first as any) = typeof v === "function" ? (v as (prev: T) => T)(first as T) : v); @@ -1372,7 +1424,13 @@ export function createMemo( sharedConfig.context = ctx; const reads = asyncReads; try { - return runWithOwner(owner, () => runWithObserver(comp, () => comp.compute(comp.value))); + return runWithOwner(owner, () => + runWithObserver(comp, () => + edgeHooks !== null && edgeHooks.on() + ? edgeHooks.run(owner, () => comp.compute(comp.value)) + : comp.compute(comp.value) + ) + ); } finally { sharedConfig.context = prev; if (asyncReads !== reads) comp.moving = true; @@ -1434,6 +1492,7 @@ export function createMemo( const ssrSource = options?.ssrSource; if (ssrSource === "client") { // Skip computation. Owner created for ID parity. + if (edgeHooks !== null) edgeHooks.untracked(); comp.computed = true; if (loadingState) { // Client-source with commit #0: markup flushes the loading value; the @@ -1463,6 +1522,8 @@ export function createMemo( update(); } if (comp.moving) asyncReads++; + // Dependency edges: a read re-reports what the last compute read. + if (edgeHooks !== null) edgeHooks.report(owner); if (comp.errored) { // A client hole read outside a Loading discovery pass must fail loudly // rather than wedge the stream (see clientHoleRead). Identity check on @@ -1525,7 +1586,6 @@ function createSyncMemo( let epoch: number | undefined; // See ServerComputation.moving. let moving = false; - function pull(): T | undefined { // Inlined `runWithOwner` — avoids the per-pull `() => compute(value)` // closure alloc that `runWithOwner(owner, fn)` would force. Hot for the @@ -1545,7 +1605,11 @@ function createSyncMemo( // inline, the catch restores and re-raises. let result: T; try { - result = compute(value) as T; + result = ( + edgeHooks !== null && edgeHooks.on() + ? edgeHooks.run(owner, () => compute(value)) + : compute(value) + ) as T; } catch (err) { currentOwner = prev; if (asyncReads !== reads) moving = true; @@ -1587,6 +1651,8 @@ function createSyncMemo( return (() => { if (moving) asyncReads++; if (cached && (!ctx?.commitEpoch || ctx.commitEpoch() === epoch)) { + // Dependency edges: a read re-reports what the last compute read. + if (edgeHooks !== null) edgeHooks.report(owner); if (errored) throw error; return value; } @@ -1598,7 +1664,10 @@ 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(); + if (edgeHooks === null) return pull(); + const v = pull(); + edgeHooks.report(owner); + return v; }) as SourceAccessor; } @@ -2319,6 +2388,7 @@ function serverEffect( ): void { const ssrSource = options?.ssrSource; if (ssrSource === "client") { + if (edgeHooks !== null) edgeHooks.untracked(); createOwner(); return; } @@ -2438,7 +2508,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. + if (edgeHooks !== null) edgeHooks.untracked(); const o = getOwner(); if (o?.id != null) getNextChildId(o); } @@ -2481,7 +2553,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, (() => { @@ -2530,7 +2605,7 @@ export function createStore( return [store as Store, storeSetter(store as T)]; } const state = first as T; - return [state as Store, storeSetter(state)]; + return [(edgeHooks !== null ? edgeHooks.store(state) : state) as Store, storeSetter(state)]; } /** @@ -2573,7 +2648,12 @@ export function createOptimisticStore( // masks that revert at settle, and server output is settled state. The // setter never invokes its function — a draft mutation here would be a // literal (permanent) mutation, the opposite of optimistic. - const [store] = (createStore as Function)(first, second, third) as [Store, StoreSetter]; + // An object seed is served as is: not named for dependency edges (the + // client names no optimistic store; a write to one mounts everything). + const store = + typeof first === "function" + ? ((createStore as Function)(first, second, third) as [Store, StoreSetter])[0] + : (first as Store); return [store, (() => warnServerWrite("optimistic")) as StoreSetter]; } @@ -2739,6 +2819,9 @@ export function createProjection( options?: ServerProjectionOptions ): Store { const ctx = sharedConfig.context; + // Dependency edges: a projection's readers are not tracked (store + // reads report nothing), so the signals its compute reads are WIDE. + if (ctx && edgeHooks !== null) fn = edgeHooks.projection(ctx, fn); const owner = createOwner(); // The projection's own owner, read at creation — never a swapped hole scope. const id = ownerId(owner); @@ -2768,7 +2851,9 @@ export function createProjection( return proxy; }; // Seedless: the root starts as an empty record (the client's base too). - const [state] = createStore((seed || {}) as NoFn); + // The seed itself, not a `createStore` over it: a projection's state is + // not a plain store (no dependency-edge name; the client names none). + const state = (seed || {}) as T; if (options?.ssrSource === "client") { // seedLoadingValue = declared commit #0: the seed renders. Bare = the @@ -3314,6 +3399,9 @@ export function mapArray( // Reset per pass so retries re-synthesize row/fallback ids from 0, // like the client's fresh row owners on its first successful pass. rowOwner._childCount = 0; + // Edges: the rows' plain-signal counts restart with each pass (a row's + // id moving on restarts its own; nothing in the row loop itself). + if (edgeHooks !== null) edgeHooks.reset(rowOwner); if (items && items.length) { runWithOwner(rowOwner as unknown as Owner, () => { // Own owner, not a swapped hole scope: a row's holes swap it only @@ -3420,6 +3508,7 @@ export function repeat( const len = count(); const offset = options.from?.() || 0; rowOwner._childCount = 0; + if (edgeHooks !== null) edgeHooks.reset(rowOwner); if (!len) { if (!options.fallback) return []; const fo = runWithOwner(rowOwner as unknown as Owner, () => createOwner()); diff --git a/packages/solid/test/server/signal-identities.spec.ts b/packages/solid/test/server/signal-identities.spec.ts new file mode 100644 index 0000000000..92baee74ce --- /dev/null +++ b/packages/solid/test/server/signal-identities.spec.ts @@ -0,0 +1,181 @@ +/** @vitest-environment node */ +/** + * Dependency edges on the server: plain-signal identities and read tracking + * (edges.ts). Off until the frames server installs them, so a render without + * server components names nothing and tracks nothing. On, a plain signal + * created under an id-bearing owner is named `~`, n restarting + * whenever the owner's child counter does (dispose, retry reset), as the + * client names it; a slot fill's reads report those names, through memos too. + */ +import { afterEach, describe, expect, test } from "vitest"; +import { + createMemo, + createOwner, + createRoot, + createProjection, + createSignal, + createStore, + edgeHooks, + mapArray, + resetOwnerForRerun, + runWithOwner +} from "../../src/server/signals.js"; +import { beginEdgesRender, edgesRenders, trackFillReads } from "../../src/server/edges.js"; +import { sharedConfig } from "../../src/server/shared.js"; + +let end: (() => void) | undefined; +afterEach(() => { + end?.(); + end = undefined; + sharedConfig.context = undefined; +}); + +/** A render that asked for edges is in flight. */ +function edgesRender() { + end = beginEdgesRender(); + sharedConfig.context = { edges: true } as any; +} + +const readsOf = (fn: () => unknown) => [...(trackFillReads(fn).reads || [])]; + +describe("server plain-signal identities", () => { + test("off: nothing is named or tracked", () => { + const reads = createRoot( + () => { + const [a] = createSignal(1); + return readsOf(() => a()); + }, + { id: "off" } + ); + expect(reads).toEqual([]); + }); + + test("installed, but the render did not ask: nothing is named", () => { + end = beginEdgesRender(); + sharedConfig.context = { edges: false } as any; + const reads = createRoot( + () => { + const [a] = createSignal(1); + return readsOf(() => a()); + }, + { id: "plain" } + ); + expect(reads).toEqual([]); + }); + + test("on: named per id-bearing owner in creation order; fills see reads through memos", () => { + edgesRender(); + const reads = createRoot( + () => { + const [a] = createSignal(1); + const [b] = createSignal(2); + const sum = createMemo(() => a() + b()); + return [readsOf(() => a()), readsOf(() => sum())]; + }, + { id: "r" } + ); + expect(reads[0]).toEqual(["r~0"]); + expect(reads[1].sort()).toEqual(["r~0", "r~1"]); + }); + + test("the count restarts when the owner's children reset (a retry)", () => { + edgesRender(); + const names = createRoot( + () => { + const o = createOwner(); + const pass = () => + runWithOwner(o, () => { + const [s] = createSignal(0); + return readsOf(() => s()); + }); + const first = pass(); + resetOwnerForRerun(o); + return [first, pass()]; + }, + { id: "q" } + ); + expect(names[0]).toHaveLength(1); + expect(names[1]).toEqual(names[0]); + }); + + test("rows of one list count their own signals, from 0 on every pass", () => { + edgesRender(); + const passes = createRoot( + () => { + const [items] = createSignal(["a", "b", "c"]); + const rows = mapArray(items, () => { + const [s] = createSignal(0); + const [t] = createSignal(0); + return readsOf(() => (s(), t())); + }); + return [rows(), rows()]; + }, + { id: "l" } + ); + const [first] = passes; + const names = first.flat(); + expect(new Set(names).size).toBe(6); + // Each row's two signals: `~0` and `~1`. + for (const row of first) expect(row.map(n => n.split("~")[1])).toEqual(["0", "1"]); + expect(passes[1]).toEqual(first); + }); + + test("a plain store is named by its root: any read of it, at any depth, reports that name", () => { + edgesRender(); + const r = createRoot( + () => { + const [a] = createSignal(0); + const [store] = createStore({ user: { name: "a" }, tags: ["x", "y"], n: 1 }); + const proj = createProjection<{ v: number }>(() => {}, { v: 0 }); + const [b] = createSignal(0); + return { + a: readsOf(() => a()), + b: readsOf(() => b()), + leaf: readsOf(() => store.user.name), + keys: readsOf(() => Object.keys(store.user)), + iterate: readsOf(() => store.tags.map(t => t)), + has: readsOf(() => "n" in store), + proj: readsOf(() => proj.v), + same: store.user === store.user + }; + }, + { id: "st" } + ); + // Signals around it keep their counts: the store took `st~1`, the + // projection none. + expect(r.a).toEqual(["st~0"]); + expect(r.b).toEqual(["st~2"]); + for (const reads of [r.leaf, r.keys, r.iterate, r.has]) expect(reads).toEqual(["st~1"]); + expect(r.proj).toEqual([]); + expect(r.same).toBe(true); + }); + + test("a store held inside another store makes the holder wide", () => { + edgesRender(); + const ctx = sharedConfig.context as any; + const r = createRoot( + () => { + const [inner] = createStore({ n: 0 }); + const [outer] = createStore({ inner }); + return readsOf(() => outer.inner.n); + }, + { id: "w" } + ); + // The held store reads as itself; the holder is wide (a setter call on + // it can write the held store, whose own readers its name misses). + expect(r.sort()).toEqual(["w~0", "w~1"]); + expect([...ctx.edgeWide.s]).toEqual(["w~1"]); + }); + + test("the hooks are installed only while an edges render is in flight", () => { + const a = beginEdgesRender(); + const b = beginEdgesRender(); + expect(edgesRenders()).toBe(2); + a(); + a(); + expect(edgesRenders()).toBe(1); + b(); + expect(edgesRenders()).toBe(0); + expect(edgeHooks).toBeNull(); + }); +}); diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 94d5b26b64..d823919371 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 @@ -62,7 +63,15 @@ import { stagedContent, type ServerComponentHandlerOptions } from "./frame-transport.js"; -import { createLoadingBoundary, setWriteHook, sharedConfig } from "solid-js/internal"; +import { + createLoadingBoundary, + setReadWatch, + setSignalIdentities, + setWriteHook, + signalName, + sharedConfig, + storeName +} 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 +1281,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 +1603,171 @@ function mountAllDeferred() { deferDone(); } +// Dependency edges. The server ships, per document frame, which occurrences +// read which plain signals and plain stores they did not create +// (`sc:deps::` batches, `sid -> "card#0-399 cart#0"`; `*`: wide +// names, whose readers it could not track; `!`: occurrences with reads it +// could not see). A store goes by its root's name: any read of it is a read +// of that name, any setter call on it a write. A write to a name mounts only +// the unmounted occurrences that read it — each with the occurrences +// enclosing it first, as an event would — before the write applies; a name +// no record lists mounts nothing (no unmounted occurrence read it). +// Everything the records cannot vouch for mounts everything left: a write +// with no name (a projection or optimistic store, a store named twice, a +// derived or optimistic signal, a signal or store of an owner without an +// id), a wide name, a write while the document still streams (records may +// not have arrived), or a deferred frame under a root that shipped no +// records. +const edgeRoots = new Map(); +type EdgeIndex = { + readers: Map; + wide: Set; + untracked: [site: string, occurrence: 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` (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; +} +/** 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) { + 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 mountForWrite(node?: object) { + if (mounting || (sharedConfig as any).hydrating) return; + // A plain signal by its own name; a store setter by its store's. + const sid = signalName(node) ?? storeName(node); + if (sid === undefined || document.readyState === "loading") return mountAllDeferred(); + const idx = (edgeIndex ||= buildEdgeIndex()); + if (!idx || idx.wide.has(sid) || !edgesCover(idx)) return mountAllDeferred(); + const readers = idx.readers.get(sid); + if (!readers && !idx.untracked.length) return; + 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); + 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) for (let f; (f = liveFrameAt(target)) && f.mountEnclosing(target); ); + } + } finally { + mounting = false; + } + 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(node => { + const sid = signalName(node); + if (sid !== undefined) 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 () => { @@ -1626,6 +1801,7 @@ function deferDone() { if (deferredFrames().length) return; if (deferring) { deferring = false; + edgeIndex = driftReads = undefined; setWriteHook(null); DEFER_EVENTS.forEach(t => document.removeEventListener(t, wakeAt, true)); } @@ -1642,13 +1818,15 @@ 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; 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,7 +1930,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(); - if (typeof document !== "undefined" && deferHydration) setDeferDriver(deferFrame); + 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); + } 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 b42fc904dd..5413203015 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -1107,6 +1107,18 @@ export let deferDriver: ((frame: any) => void) | null = null; export function setDeferDriver(driver: ((frame: any) => void) | null): void { deferDriver = driver; } +/** + * @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); @@ -1970,9 +1982,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); + this.#deferMounted.set(id, start); return true; } @@ -1993,8 +2014,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); + // 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; @@ -2004,6 +2027,28 @@ class FrameImpl { return true; } + /** + * 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(); + this.#collectSlots(found); + } + const start = found.get(id); + if (!start) return false; + found.delete(id); + this.#mountOne(id, start); + 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 4b0ccc4a37..58c392eb4a 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. */ +/** + * 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(" "); +} + +/** 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,45 @@ export function createDocumentSlotProps( export function createDocumentSlotProps(clientProps, frameId) { const counts = Object.create(null); + // 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; + const depsContext = sharedConfig.context; + const noteDeps = (occurrence: string, reads: Set | null, untracked: boolean) => { + // Records only from a render that names its signals (`edges`, set at + // render start when the server-components codec is configured): without + // them the client mounts everything on a write, as before. + if (!depsContext || !depsContext.serialize || !(depsContext as any).edges) return; + const own = `sc-${frameId}-${occurrence}-`; + const batch = () => { + if (depsBatch) return depsBatch; + 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); + }); + return (depsBatch = Object.create(null) as Record); + }; + 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). const repeats = new Map(); @@ -1756,9 +1834,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, 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/src/server.ts b/packages/web/src/server.ts index f70a448da0..bd2f86dc71 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -26,7 +26,8 @@ import { sourceOwners, ssrScope as scope, ssrSanitizeError, - reportServerError + reportServerError, + beginEdgesRender } from "solid-js/internal"; import type { Owner } from "solid-js"; import type { ServerErrorSite } from "solid-js/internal"; @@ -2769,6 +2770,11 @@ export function renderToStream(code, options = {}) { // frame stream (`options.sink`). Read by the server runtime's dev check // on undeclared unbounded sources, which only a document render pays for. document: !options.sink, + // Dependency edges (deferred hydration): a render configured with the + // server-components codec names its plain signals and records which ones + // each slot fill reads (solid's server edges.ts). Every other render + // takes the plain paths. + edges: !!options.plugins && options.plugins.some(p => p.tag === "solid/server-component"), // The document face's live-hole carrier (Stage 4). Components render // under per-component context CLONES (spread copies), so a mutation on // the clone a server component armed under never reaches this root @@ -3080,44 +3086,55 @@ export function renderToStream(code, options = {}) { const onAbort = signal ? () => abandon("signal", !!(signal.reason && signal.reason.quiet === true)) : undefined; - let html = root( - d => { - dispose = () => { - // The render is over: no later read finds its trace (see above). - context.trace = undefined; - if (onAbort) signal.removeEventListener("abort", onAbort); - d(); - }; - rootOwner = claimRenderRoot(context); - if (requestEvent) claimEventRender(requestEvent, context); - let res; - try { - res = resolveSSRNode(escape(code())); - } catch (err) { - // The first pass throws out of `renderToStream` to the caller: the - // hook hears it first, as for `renderToString`. - reportServerError( - err, - { kind: "render", handling: "failed" }, - null, - options.onError, - errorScope - ); - throw err; - } - if (!res.h.length) return res.t[0]; - rootHoles = []; - let out = res.t[0]; - for (let i = 0; i < res.h.length; i++) { - const id = nextHoleId++; - rootHoles.push({ id, fn: res.h[i] }); - out += `` + res.t[i + 1]; - } - for (const p of res.p) blockingPromises.add(p); - return out; - }, - { id: renderId } - ); + // Dependency edges: the server's signal-naming hooks are installed while + // an edges render is in flight; this one releases them at its final + // dispose, or here if the root pass throws. + const endEdges = context.edges ? beginEdgesRender() : undefined; + let html; + try { + html = root( + d => { + dispose = () => { + // The render is over: no later read finds its trace (see above). + context.trace = undefined; + if (onAbort) signal.removeEventListener("abort", onAbort); + if (endEdges) endEdges(); + d(); + }; + rootOwner = claimRenderRoot(context); + if (requestEvent) claimEventRender(requestEvent, context); + let res; + try { + res = resolveSSRNode(escape(code())); + } catch (err) { + // The first pass throws out of `renderToStream` to the caller: the + // hook hears it first, as for `renderToString`. + reportServerError( + err, + { kind: "render", handling: "failed" }, + null, + options.onError, + errorScope + ); + throw err; + } + if (!res.h.length) return res.t[0]; + rootHoles = []; + let out = res.t[0]; + for (let i = 0; i < res.h.length; i++) { + const id = nextHoleId++; + rootHoles.push({ id, fn: res.h[i] }); + out += `` + res.t[i + 1]; + } + for (const p of res.p) blockingPromises.add(p); + return out; + }, + { id: renderId } + ); + } catch (err) { + if (endEdges) endEdges(); + throw err; + } if (onAbort) { // A request already gone when the render starts has nobody to render // for: tear down now, after the root pass so the finding counts what it 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 d65376f456..5a93abd20d 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 5dbbb2f103..eceb2208ec 100644 --- a/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json +++ b/packages/web/test/harness/__artifacts__/dynamic-component-parity-streamed.json @@ -1,5 +1,5 @@ { "name": "dynamic-component-parity-streamed", "shell": "

client

shell-fallback

", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-dependency-edges.json b/packages/web/test/harness/__artifacts__/frame-dependency-edges.json new file mode 100644 index 0000000000..9c471b6fa2 --- /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 18d144f8fc..955ebcc6e9 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-loaded", - "shell": "

shell-fallback

", + "shell": "

shell-fallback

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

shell-fallback

", + "shell": "

shell-fallback

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

shell-fallback

", + "shell": "

shell-fallback

", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json b/packages/web/test/harness/__artifacts__/frame-nested-fill-context.json index 6087b6587f..9e8a87c643 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 51bbf11aef..50f9571d46 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 515cd5f6ae..08fcfec908 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-streamed-loaded", "shell": "

shell-fallback

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

shell-fallback

", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-occlusion-document.json b/packages/web/test/harness/__artifacts__/frame-occlusion-document.json index 9d28469a1d..72ccb305c8 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 c83103423e..4638b116b4 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 a6646ffb23..c1bc8bf555 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json @@ -1,5 +1,5 @@ { "name": "welcome-status-loaded", - "shell": "
1…
", - "rest": "" + "shell": "
1…
", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json index 4551321469..27ba8f8dc6 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json @@ -1,5 +1,5 @@ { "name": "welcome-status-streamed", - "shell": "
1…
", - "rest": "" + "shell": "
1…
", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/frame-dependency-edges.tsx b/packages/web/test/harness/frame-dependency-edges.tsx new file mode 100644 index 0000000000..85755b2acd --- /dev/null +++ b/packages/web/test/harness/frame-dependency-edges.tsx @@ -0,0 +1,199 @@ +/** + * @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 state: a click + * counter and a card-local store; + * - `cart` reads `cart`; + * - `store` reads a plain store (named by its root, like a signal); + * - `dstore` reads a derived store (a projection: its writes carry no + * name); + * - `wide` reads a projection derived from `wideSource` (a projection's + * readers are not tracked, so `wideSource` is wide); + * - `derived` reads a writable derived signal (no name either); + * - `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`. + * + * `loose` is a store created outside any owner: it has no name, so a write + * to it mounts everything. + * + * `drift` (client only) creates one extra plain signal in the shell ahead + * of the shared ones, so the client names them differently from the server. + * + * 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; + dstore: { v: number }; + setDStore: (fn: (s: { v: number }) => void) => void; + setWideSource: Setter; + wide: { v: number }; + derived: Accessor; + setDerived: Setter; +}; +export const ShopCtx = createContext(); + +/** A store created outside any owner: unnamed. */ +export const [loose, setLoose] = createStore({ n: 0 }); + +/** Each fill mount, by name; the last shell's state, for the test's writes. */ +export const mounted: string[] = []; +export let shop: Shop; + +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 [dstore, setDStore] = createStore<{ v: number }>(() => {}, { v: 0 }); + const wide = createProjection<{ v: number }>( + d => { + d.v = wideSource(); + }, + { v: 0 } + ); + const [derived, setDerived] = createSignal(() => 0); + shop = { + cart, + setCart, + currency, + setCurrency, + store, + setStore, + dstore, + setDStore, + setWideSource, + wide, + derived, + setDerived + }; + return {props.children}; +} + +export function Item(props: { id: string }) { + const s = useContext(ShopCtx)!; + const [clicks, setClicks] = createSignal(0); + const [own, setOwn] = createStore({ n: 0 }); + mounted.push(`item:${untrack(() => props.id)}`); + const click = () => { + setClicks(c => c + 1); + setOwn(o => void (o.n += 10)); + }; + return ( + + ); +} +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 DerivedStoreView() { + const s = useContext(ShopCtx)!; + mounted.push("dstore"); + return

dstore:{s.dstore.v}

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

wide:{s.wide.v}

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

derived:{s.derived()}

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

late:{doubled()}

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

}> + } + cart={() => } + store={() => } + dstore={() => } + 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; + dstore: 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 0000000000..25496b437e --- /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: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 0000000000..d2822a8348 --- /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 0000000000..886d0d15cb --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-helpers.tsx @@ -0,0 +1,70 @@ +/** + * @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", + "dstore", + "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 0000000000..72ba6be6e4 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-local.spec.tsx @@ -0,0 +1,28 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write to an occurrence's own state (a +// named signal and a card-local store 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 and its card-local store, 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:10"); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges-projection.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-projection.spec.tsx new file mode 100644 index 0000000000..07d1861006 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-projection.spec.tsx @@ -0,0 +1,27 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write to a derived store (a projection) +// carries no name: it 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 projection store mounts every occurrence left first", () => { + const page = bootEdges(); + shop.setCart(1); + expect(page.seen()).toEqual(["cart", "late"]); + shop.setDStore(s => void (s.v = 2)); + expect(page.seen()).toEqual(ALL); + flush(); + expect(page.text("dstore")).toBe("dstore:2"); + expect(page.text("item-a")).toBe("a:USD:0:0"); + expect(page.warnings).toEqual([]); + page.dispose(); + }); +}); diff --git a/packages/web/test/hydration/frame-dependency-edges-store-unnamed.spec.tsx b/packages/web/test/hydration/frame-dependency-edges-store-unnamed.spec.tsx new file mode 100644 index 0000000000..b5d7827822 --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-store-unnamed.spec.tsx @@ -0,0 +1,24 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a write to a store with no name (created +// outside any owner) is one the records cannot vouch for: it 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 { setLoose } 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 an unnamed store mounts every occurrence left first", () => { + const page = bootEdges(); + setLoose(s => void (s.n = 1)); + expect(page.seen()).toEqual(ALL); + flush(); + expect(page.text("item-a")).toBe("a:USD:0:0"); + 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 0000000000..291206e7da --- /dev/null +++ b/packages/web/test/hydration/frame-dependency-edges-store.spec.tsx @@ -0,0 +1,30 @@ +/** + * @jsxImportSource @solidjs/web + */ +// Dependency edges — client half: a plain store goes by its root's name. A +// setter call on it mounts only the unmounted occurrences that read the +// store (any property, at any depth), before the write applies. +import { describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +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, stores", () => { + test("a write to a named store mounts only the occurrences that read it", () => { + const page = bootEdges(); + const before = page.container.querySelector("#store"); + shop.setStore(s => void (s.n = 1)); + // The store widget, and `late` (it mounts on every named write); the + // items, the projection's reader and the rest stay unmounted. + expect(page.seen()).toEqual(["late", "store"]); + // Mounted against the server's markup before the write: hydrated in place. + expect(page.text("store")).toBe("store:0"); + flush(); + expect(page.text("store")).toBe("store:1"); + expect(page.container.querySelector("#store")).toBe(before); + 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 0000000000..e6b210715b --- /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: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 0000000000..553703838d --- /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: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 0000000000..3d05d2dcfe --- /dev/null +++ b/packages/web/test/server/frame-dependency-edges.spec.tsx @@ -0,0 +1,58 @@ +/** + * @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 and its plain store, 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, store] = [0, 1, 2, 3].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"); + // The store, by its root's name: the store widget read it. + expect(deps[store]).toBe("store#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 card-local stores) and + // unnamed sources (the projection store, the derived signal, the store + // created outside any owner) are not edges. + expect(named.sort()).toEqual([cart, currency, store].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 7dcfa8fa83..1792017253 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 53236 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "33.62 KB", - "minified": 103298 + "cap": "34.28 KB", + "minified": 105776 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "37.78 KB", - "minified": 116698 + "cap": "38.57 KB", + "minified": 119182 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index f8e9776f15..4e14d789e8 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4563,8 +4563,25 @@ module.exports = [ // measured + 10 B rounded up to 0.01 KB. // record-only writes mount from the last slot walk (#4139, 2026-10-18): 12.25 -> 12.37 KB, measured locally at 12,359 B brotli / 37,310 B minified (+465 B minified over the recorded size): a write carrying only slot records mounts the occurrences it names from the frame's cached walk instead of walking the frame again (eager hydration of a 400-slot page walked it 401 times). Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 12.37 KB -> 12.46 KB, measured locally at 12,445 B brotli / 37,516 B minified (+206 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. - limit: "12.46 KB", - capMinified: 37516, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 12.25 -> 12.80 KB, measured locally (linux) at 12,784 B + // brotli / 38,793 B minified against next's 12,235 / 36,845 (+549 B; +1948 + // B minified). Deferred hydration mounts only the slot occurrences the + // server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 12.80 KB -> 12.99 KB, measured locally (linux) at 12,980 B brotli / 39,466 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "12.99 KB", + capMinified: 39466, alias: framesAlias, external: framesExternal }, @@ -4932,6 +4949,23 @@ module.exports = [ // slot records mounts the occurrences it names from the frame's cached // walk instead of walking the frame again. Accepted by the maintainer. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 33.57 KB -> 33.62 KB (floor-caps.json), measured locally at 33,608 B brotli / 103,298 B minified (+196 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap 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 -> 34.08 KB, measured locally (linux) at 34,065 B + // brotli / 105,105 B minified against next's 33,441 / 102,629 (+624 B; + // +2476 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 34.08 KB -> 34.28 KB (floor-caps.json), measured locally (linux) at 34,270 B brotli / 105,776 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). 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)"], @@ -5246,6 +5280,23 @@ module.exports = [ // brotli / 116,502 B minified (+465 B minified). Accepted by the // maintainer. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 37.67 KB -> 37.78 KB (floor-caps.json), measured locally at 37,768 B brotli / 116,698 B minified (+196 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap 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.34 KB, measured locally (linux) at 38,322 B + // brotli / 118,464 B minified against next's 37,565 / 115,982 (+757 B; + // +2482 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 38.34 KB -> 38.57 KB (floor-caps.json), measured locally (linux) at 38,560 B brotli / 119,182 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). 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)"], @@ -5378,8 +5429,25 @@ module.exports = [ // measured + 10 B rounded up to 0.01 KB. // record-only writes mount from the last slot walk (#4139, 2026-10-18): 35.42 -> 35.53 KB, measured locally at 35,517 B brotli / 109,446 B minified (+473 B minified over the recorded size): a write carrying only slot records mounts the occurrences it names from the frame's cached walk instead of walking the frame again (eager hydration of a 400-slot page walked it 401 times). Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 35.53 KB -> 35.58 KB, measured locally at 35,569 B brotli / 109,642 B minified (+196 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. - limit: "35.58 KB", - capMinified: 109642, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 35.42 -> 36.10 KB, measured locally (linux) at 36,087 B + // brotli / 111,454 B minified against next's 35,408 / 108,973 (+679 B; + // +2481 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 36.10 KB -> 36.42 KB, measured locally (linux) at 36,407 B brotli / 112,125 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "36.42 KB", + capMinified: 112125, alias: pageAlias }, { @@ -5481,8 +5549,25 @@ module.exports = [ // the maintainer (2026-10-17). // record-only writes mount from the last slot walk (#4139, 2026-10-18): 41.48 -> 41.64 KB, measured locally at 41,628 B brotli / 124,449 B minified (+523 B minified over the recorded size): a write carrying only slot records mounts the occurrences it names from the frame's cached walk instead of walking the frame again (eager hydration of a 400-slot page walked it 401 times). Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 41.64 KB -> 41.66 KB, measured locally at 41,649 B brotli / 124,645 B minified (+196 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. - limit: "41.66 KB", - capMinified: 124645, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 41.48 -> 42.18 KB, measured locally (linux) at 42,169 B + // brotli / 126,460 B minified against next's 41,439 / 123,929 (+730 B; + // +2531 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 42.18 KB -> 42.39 KB, measured locally (linux) at 42,375 B brotli / 127,178 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "42.39 KB", + capMinified: 127178, alias: pageAlias }, { @@ -5654,8 +5739,25 @@ module.exports = [ // raised. // record-only writes mount from the last slot walk (#4139, 2026-10-18): 40.90 -> 40.95 KB, measured locally at 40,937 B brotli / 126,936 B minified (+476 B minified over the recorded size): a write carrying only slot records mounts the occurrences it names from the frame's cached walk instead of walking the frame again (eager hydration of a 400-slot page walked it 401 times). Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 40.95 KB -> 41.04 KB, measured locally at 41,022 B brotli / 127,132 B minified (+196 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. - limit: "41.04 KB", - capMinified: 127132, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 40.90 -> 41.66 KB, measured locally (linux) at 41,647 B + // brotli / 128,940 B minified against next's 40,817 / 126,463 (+830 B; + // +2477 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 41.66 KB -> 41.81 KB, measured locally (linux) at 41,799 B brotli / 129,611 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "41.81 KB", + capMinified: 129611, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5803,8 +5905,25 @@ module.exports = [ // (2026-10-17). // record-only writes mount from the last slot walk (#4139, 2026-10-18): 47.05 -> 47.19 KB, measured locally at 47,173 B brotli / 141,725 B minified (+470 B minified over the recorded size): a write carrying only slot records mounts the occurrences it names from the frame's cached walk instead of walking the frame again (eager hydration of a 400-slot page walked it 401 times). Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. // Flush content passes walk only non-slot keys (#4139, 2026-10-18): 47.19 KB -> 47.23 KB, measured locally at 47,220 B brotli / 141,921 B minified (+196 B minified over the recorded size): a document's slot record writes no longer each enumerate the whole record store; shop page JS at 4x, eager 564 -> 361 ms, deferred 516 -> 375. Accepted by the maintainer (2026-10-18). Cap at measured + 10 B rounded up to 0.01 KB. - limit: "47.23 KB", - capMinified: 141921, + // Size-Exception (dependency edges for deferred hydration, 2026-10-17, over + // next @ fb498491): 47.02 -> 47.74 KB, measured locally (linux) at 47,722 B + // brotli / 143,747 B minified against next's 47,006 / 141,205 (+716 B; + // +2542 B minified). Deferred hydration mounts only the slot occurrences + // the server recorded as readers of the written signal or store (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. The plain-signal + // namer (`~`, restarted on each owner pass) ships with the + // frames client, which installs it into a null-checked slot in + // createSignal; a plain store goes by its root's name on the same count + // (createStore calls the same slot, and the write hook resolves a store + // proxy or property node to it); signals and app scenarios keep their + // minified size, except the compiled hydrating app (+3 B: a store setter + // hands its proxy to the write hook). Cap at measured + 10 B rounded up to + // 0.01 KB; recorded minified = measured. + // Re-measured after merging next @ ed51af36 (#4139 landed first): 47.74 KB -> 47.97 KB, measured locally (linux) at 47,954 B brotli / 144,465 B minified; the edges growth over that next is unchanged in kind. Accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "47.97 KB", + capMinified: 144465, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }