From c4ba6c78910cce79e617e3711ff5ec4409329c1a Mon Sep 17 00:00:00 2001 From: Leon Schiffler Date: Wed, 16 Sep 2026 12:15:20 -0700 Subject: [PATCH] fix(store): preserve shallow leaves and sequence hybrid hydration --- .changeset/shallow-store-hydration.md | 6 + packages/signals/src/store/next/projection.ts | 29 ++- .../tests/store/shallow-loading.test.ts | 55 +++++ packages/signals/tests/treeshake.test.ts | 4 +- packages/solid/src/client/hydration.ts | 69 ++++-- packages/solid/src/server/signals.ts | 40 +++- .../test/server/shallow-projection.spec.ts | 55 +++++ packages/solid/test/shallow-hydration.spec.ts | 203 ++++++++++++++++++ scripts/size/.size-limit.js | 13 +- 9 files changed, 425 insertions(+), 49 deletions(-) create mode 100644 .changeset/shallow-store-hydration.md create mode 100644 packages/signals/tests/store/shallow-loading.test.ts create mode 100644 packages/solid/test/server/shallow-projection.spec.ts create mode 100644 packages/solid/test/shallow-hydration.spec.ts diff --git a/.changeset/shallow-store-hydration.md b/.changeset/shallow-store-hydration.md new file mode 100644 index 000000000..5f0a3e985 --- /dev/null +++ b/.changeset/shallow-store-hydration.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +--- + +Preserve shallow store leaf identity in computed drafts, loading snapshots, and SSR hydration. Wait for the server answer and hydration completion before hybrid store takeover, and suppress the first client yield only during the initial handoff. diff --git a/packages/signals/src/store/next/projection.ts b/packages/signals/src/store/next/projection.ts index aee4589d0..12e283bba 100644 --- a/packages/signals/src/store/next/projection.ts +++ b/packages/signals/src/store/next/projection.ts @@ -64,7 +64,8 @@ import type { StoreNextFamily } from "./target.js"; function wrapDraft( inner: any, isActive?: () => boolean, - aroundWrite?: (op: () => void) => void + aroundWrite?: (op: () => void) => void, + wrap: (value: any, active?: () => boolean, write?: (op: () => void) => void) => any = wrapDraft ): any { const write = (op: () => void) => (aroundWrite ? aroundWrite(op) : op()); const traps: ProxyHandler = { @@ -79,9 +80,8 @@ function wrapDraft( setWriteOverride(false); setProjectionWriteActive(was); } - if (prop === $TARGET) return value; - return typeof value === "object" && value !== null - ? wrapDraft(value, isActive, aroundWrite) + return typeof value === "object" && value !== null && prop !== $TARGET + ? wrap(value, isActive, aroundWrite) : value; }, has(_, prop) { @@ -236,6 +236,16 @@ export function createStoreDerivedNext( ]; } +function cloneProjection(value: T, shallow?: boolean): T { + return ( + shallow + ? Array.isArray(value) + ? value.slice() + : { ...value } + : JSON.parse(JSON.stringify(value)) + ) as T; +} + export function runProjectionComputedNext( wrappedStore: Store, fn: (draft: T) => void | T | Promise | AsyncIterable, @@ -244,19 +254,20 @@ export function runProjectionComputedNext( aroundDraftWrite?: (op: () => void) => void ): Computed { const owner = getOwner() as Computed; + const target = (wrappedStore as any)[$TARGET]; let settled = false; let result: void | T | Promise | AsyncIterable; // Open loading window (seedLoadingValue): the observable store IS commit #0 // for the whole first flight — the derive works a detached shadow of the // seed so draft writes cannot tear through to readers (#2988). Every commit // point reconciles the shadow through the normal commit path. - const shadow = owner._loading - ? (JSON.parse(JSON.stringify((wrappedStore as any)[$TARGET][STORE_VALUE])) as T) - : null; + const shadow = owner._loading ? cloneProjection(target[STORE_VALUE], target.s) : null; + // Choose leaf wrapping once; Object is identity for the object-valued reads below. const draft = wrapDraft( wrappedStore, () => !settled || owner._x?._inFlight === result, - aroundDraftWrite + aroundDraftWrite, + target.s ? Object : wrapDraft ); storeSetterNext( draft, @@ -268,7 +279,7 @@ export function runProjectionComputedNext( // (adoption takes the value by identity — handing it the live shadow // would fuse the draft to the observable store). if (shadow && (v === undefined || v === (shadow as any))) - v = JSON.parse(JSON.stringify(shadow)) as T; + v = cloneProjection(shadow, target.s); if (v === (s as any) || v === undefined) return; const write = () => storeSetterNext(wrappedStore, st => reconcileNextState(v, st, key, true), false); diff --git a/packages/signals/tests/store/shallow-loading.test.ts b/packages/signals/tests/store/shallow-loading.test.ts new file mode 100644 index 000000000..dbfbfffd9 --- /dev/null +++ b/packages/signals/tests/store/shallow-loading.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; +import { + createRoot, + createStore, + createOptimisticStore, + flush, + NotReadyError +} from "../../src/index.js"; + +describe("shallow loading projections", () => { + for (const optimistic of [false, true]) + for (const array of [false, true]) + for (const loading of [false, true]) { + test(`retains raw leaf identity: optimistic=${optimistic}, array=${array}, loading=${loading}`, async () => { + const original = Object.freeze({ + time: new Date(0), + missing: undefined, + n: NaN, + key: {} + }); + const replacement = Object.freeze({ ...original, time: new Date(1) }); + let release!: () => void, dispose!: () => void, seen: unknown; + const gate = new Promise(resolve => { + release = resolve; + }); + const seed: { [key: number]: typeof original | undefined } = array + ? [original] + : { 0: original }; + const [state] = createRoot(d => { + dispose = d; + return (optimistic ? createOptimisticStore : createStore)( + async draft => { + seen = draft[0]; + draft[0] = replacement; + await gate; + }, + seed, + { shallow: true, seedLoadingValue: loading } + ); + }); + try { + expect(seen).toBe(original); + if (loading) expect(state[0]).toBe(original); + else expect(() => state[0]).toThrow(NotReadyError); + release(); + for (let i = 0; i < 30; i++) await Promise.resolve(); + flush(); + expect(state[0]).toBe(replacement); + } finally { + release(); + dispose(); + } + }); + } +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 797ca857b..23c07b67b 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -46,7 +46,7 @@ async function bundleFixture(code: string): Promise<{ const chunk = result[0].output[0]; const retained = Object.entries(chunk.modules) .filter(([, mod]) => mod.renderedLength > 0) - .map(([id]) => id.replace(SRC + "/", "")); + .map(([id]) => id.replace(SRC.replaceAll("\\", "/") + "/", "")); // Vite lib-mode ES output is not truly minified; match the #2883 harness // (esbuild minify + `_`-prefixed property mangling, as the dist build does). const minified = await transformWithEsbuild(chunk.code, "out.js", { @@ -414,7 +414,7 @@ describe("pay-for-use tree-shaking (#2883)", () => { } })) as Rollup.RollupOutput[]; const chunk = result[0].output[0]; - const distRoot = dirname(DIST) + "/"; + const distRoot = dirname(DIST).replaceAll("\\", "/") + "/"; return Object.entries(chunk.modules) .filter(([, mod]) => mod.renderedLength > 0) .map(([id]) => id.replace(distRoot, "")); diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 78f32ce6e..0639526f9 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -563,8 +563,12 @@ function isAsyncIterable(v: any): boolean { return v != null && typeof v[Symbol.asyncIterator] === "function"; } -function createShadowDraft(realDraft: any) { - const shadow = JSON.parse(JSON.stringify(realDraft)); +function createShadowDraft(realDraft: any, shallow?: boolean) { + const shadow = shallow + ? Array.isArray(realDraft) + ? realDraft.slice() + : { ...realDraft } + : JSON.parse(JSON.stringify(realDraft)); let useShadow = true; return { proxy: new Proxy(shadow, { @@ -721,7 +725,7 @@ function hydrateStoreFromAsyncIterable( // dependencies read before the first suspension are tracked. Writes go // to a shadow of the draft and are discarded — the server iterator is // authoritative and drives the real draft via the iterable below. - const { proxy } = createShadowDraft(draft); + const { proxy } = createShadowDraft(draft, options?.shallow); subFetch(fn, proxy); const process = (res: any) => { if (res.done) { @@ -1130,27 +1134,46 @@ function hydrateStoreLikeFn( ); } if (ssrSource === "hybrid") { - return withHydrationGate(hydrated => - coreFn( - (draft: any) => { - const o = getOwner()!; - if (!hydrated()) { - if (sharedConfig.has!(o.id!)) - return readHydratedValue( - sharedConfig.load!(o.id!), - () => subFetch(fn, draft), - options - ); - return fn(draft); - } - const { proxy, activate } = createShadowDraft(draft); - const r = fn(proxy); - return isAsyncIterable(r) ? wrapFirstYield(r, activate) : r; - }, - initialValue, - options - ) + let live = false; + const id = peekNextChildId(getOwner()!); + const ready = sharedConfig.has!(id) ? sharedConfig.load!(id) : undefined; + const [hydrated, setHydrated] = coreSignal(false, { ownedWrite: true }); + const result = coreFn( + (draft: any) => { + if (live) return fn(draft); + const o = getOwner()!; + if (!hydrated()) { + if (sharedConfig.has!(o.id!)) + return readHydratedValue(sharedConfig.load!(o.id!), () => subFetch(fn, draft), options); + return fn(draft); + } + const { proxy, activate } = createShadowDraft(draft, options?.shallow); + const r = fn(proxy); + if (isAsyncIterable(r)) + return wrapFirstYield(r, () => { + live = true; + activate(); + }); + live = true; + return r; + }, + initialValue, + options ); + // A loading seed can hydrate before its first server answer arrives. + // Preserve that adoption AND the DOM claim before starting the live source. + onHydrationEnd(() => { + if (ready && typeof ready.then === "function") + ready.then( + () => setHydrated(true), + () => { + // Keep the server error until refresh, then allow a fresh client answer. + live = true; + } + ); + else setHydrated(true); + }); + return result; } const aiResult = hydrateStoreFromAsyncIterable(coreFn, fn, initialValue, options); if (aiResult !== null) return aiResult; diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index b8de5f4a7..0170f609a 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1125,7 +1125,8 @@ export type PatchOp = export function createDeepProxy( target: T, patches: PatchOp[], - basePath: PropertyKey[] = [] + basePath: PropertyKey[] = [], + shallow = false ): T { const childProxies = new Map(); @@ -1169,7 +1170,7 @@ export function createDeepProxy( } const value = Reflect.get(obj, key, receiver); - if (value !== null && typeof value === "object" && typeof key !== "symbol") { + if (!shallow && value !== null && typeof value === "object" && typeof key !== "symbol") { if (!childProxies.has(key)) { childProxies.set(key, createDeepProxy(value, patches, [...basePath, key])); } @@ -2024,17 +2025,32 @@ export function getProjectionTrace(value: unknown): ProjectionTrace | undefined return typeof value === "object" && value !== null ? projectionTraces.get(value) : undefined; } +function cloneProjection(value: T, shallow?: boolean): T { + return ( + shallow + ? Array.isArray(value) + ? value.slice() + : { ...value } + : JSON.parse(JSON.stringify(value)) + ) as T; +} + // Settles-once projections (promise-driven retry, thenable derives, hybrid // iterables): the trace is one snapshot after settlement, then done — the // border analogue of "reads pass through once markReady runs". A rejection // propagates through the iterable so the consumer's read errors rather // than hanging. -function registerSettledTrace(pending: object, ready: Promise, state: object) { +function registerSettledTrace( + pending: object, + ready: Promise, + state: object, + shallow?: boolean +) { projectionTraces.set(pending, { array: Array.isArray(state), subscribe: async function* () { await ready; - yield JSON.parse(JSON.stringify(state)); + yield cloneProjection(state, shallow); } }); } @@ -2121,7 +2137,9 @@ export function createProjection( const usesHybrid = (source: AsyncIterable) => ssrSource === "hybrid" || !!(source as any)[LIVE_SOURCE]; const patches: PatchOp[] = []; - const draft = useProxy ? createDeepProxy(state as any, patches) : (state as any as T); + const draft = useProxy + ? createDeepProxy(state as any, patches, [], options?.shallow) + : (state as any as T); const takeFirst = (source: AsyncIterable) => Promise.resolve().then(() => { const iter = source[Symbol.asyncIterator](); @@ -2149,7 +2167,7 @@ export function createProjection( // declared first paint (#2988 ruling; the client's shadow draft enforces // the same line, and hydration claims against the plain seed). const seedLoading = !!options?.seedLoadingValue; - const frozenSeed = seedLoading ? (JSON.parse(JSON.stringify(state)) as T) : undefined; + const frozenSeed = seedLoading ? cloneProjection(state, options?.shallow) : undefined; const runProjection = () => { resetOwnerForRerun(owner); @@ -2178,7 +2196,7 @@ export function createProjection( markError, () => disposed ); - registerSettledTrace(pending, deferred.promise, state); + registerSettledTrace(pending, deferred.promise, state, options?.shallow); if (ctx?.async && !getContext(NoHydrateContext) && owner.id) ctx.serialize(owner.id, deferred.promise, options?.deferStream); return recordSlot(pending); @@ -2211,7 +2229,7 @@ export function createProjection( markError, () => disposed ); - registerSettledTrace(pending, deferred.promise, state); + registerSettledTrace(pending, deferred.promise, state, options?.shallow); if (ctx?.async && !getContext(NoHydrateContext) && owner.id) ctx.serialize(owner.id, deferred.promise, options?.deferStream); return recordSlot(pending); @@ -2257,7 +2275,7 @@ export function createProjection( // `state` (for draft/patch correctness) but reads go through the frozen // copy. With seedLoadingValue the lock already sits at commit #0 — the // seed — so V1 must NOT retarget it (undefined keeps the read target). - markReady(seedLoading ? undefined : (JSON.parse(JSON.stringify(state)) as T)); + markReady(seedLoading ? undefined : cloneProjection(state, options?.shallow)); return undefined; }, markError, @@ -2313,7 +2331,7 @@ export function createProjection( let cursor = log.length; consumers++; try { - yield JSON.parse(JSON.stringify(state)) as T; + yield cloneProjection(state, options?.shallow); while (true) { if (cursor < log.length) { yield log[cursor++]; @@ -2360,7 +2378,7 @@ export function createProjection( markError, () => disposed ); - registerSettledTrace(pending, deferred.promise, state); + registerSettledTrace(pending, deferred.promise, state, options?.shallow); if (ctx?.async && !getContext(NoHydrateContext) && owner.id) ctx.serialize(owner.id, deferred.promise, options?.deferStream); return recordSlot(pending); diff --git a/packages/solid/test/server/shallow-projection.spec.ts b/packages/solid/test/server/shallow-projection.spec.ts new file mode 100644 index 000000000..6826bbb3c --- /dev/null +++ b/packages/solid/test/server/shallow-projection.spec.ts @@ -0,0 +1,55 @@ +/** @vitest-environment node */ +import { describe, expect, test } from "vitest"; +import { createRoot, createStore } from "../../src/server/index.js"; +import { getProjectionTrace } from "../../src/server/signals.js"; + +describe("shallow SSR projections", () => { + test("returns frozen leaves without wrapping them", () => { + const value = Object.freeze({ key: {} }); + createRoot(() => { + const [state] = createStore( + draft => { + expect(draft.value).toBe(value); + expect(draft.value.key).toBe(value.key); + }, + { value }, + { shallow: true } + ); + expect(state.value).toBe(value); + }); + }); + + for (const array of [false, true]) { + test(`preserves stream leaf identity and the first answer: array=${array}`, async () => { + const value = Object.freeze({ key: {}, time: new Date(0), missing: undefined, n: NaN }); + const replacement = Object.freeze({ ...value, time: new Date(1) }); + let dispose!: () => void; + const seed: { [key: number]: typeof value | undefined } = array ? [] : { 0: undefined }; + const [state] = createRoot(d => { + dispose = d; + return createStore( + async function* (draft) { + draft[0] = value; + yield; + draft[0] = replacement; + yield; + }, + seed, + { shallow: true } + ); + }); + try { + for (let i = 0; i < 30; i++) await Promise.resolve(); + expect(state[0]).toBe(value); + const iterator = getProjectionTrace(state)!.subscribe()[Symbol.asyncIterator](); + expect((await iterator.next()).value[0]).toBe(value); + const patch = (await iterator.next()).value; + expect(patch[0][1]).toBe(replacement); + expect(state[0]).toBe(value); + await iterator.return?.(); + } finally { + dispose(); + } + }); + } +}); diff --git a/packages/solid/test/shallow-hydration.spec.ts b/packages/solid/test/shallow-hydration.spec.ts new file mode 100644 index 000000000..37d9ea940 --- /dev/null +++ b/packages/solid/test/shallow-hydration.spec.ts @@ -0,0 +1,203 @@ +/** @vitest-environment jsdom */ +import { describe, expect, test, afterEach } from "vitest"; +import { createRoot, createSignal, flush, NotReadyError, refresh } from "@solidjs/signals"; +import { + enableHydration, + sharedConfig, + createStore, + createOptimisticStore +} from "../src/client/hydration.js"; +enableHydration(); +function startHydration(data: Record) { + sharedConfig.hydrating = true; + sharedConfig.has = id => id in data; + sharedConfig.load = id => data[id]; +} +function stopHydration() { + sharedConfig.hydrating = false; + sharedConfig.has = undefined; + sharedConfig.load = undefined; +} +const tick = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); + flush(); +}; + +describe("shallow hydration shadows", () => { + afterEach(stopHydration); + for (const optimistic of [false, true]) + for (const array of [false, true]) { + test(`hybrid replay retains raw leaves: optimistic=${optimistic}, array=${array}`, async () => { + const value = Object.freeze({ key: {}, date: new Date(0), missing: undefined, n: NaN }); + const next = Object.freeze({ ...value, date: new Date(1) }); + const seed: { [key: number]: typeof value | undefined } = array ? [value] : { 0: value }; + startHydration({ t0: { v: seed, s: 1 } }); + let dispose!: () => void; + const seen: unknown[] = []; + const [state] = createRoot( + d => { + dispose = d; + return (optimistic ? createOptimisticStore : createStore)( + async function* (draft) { + seen.push(draft[0]); + draft[0] = next; + yield; + draft[0] = next; + yield; + }, + seed, + { shallow: true, ssrSource: "hybrid" } + ); + }, + { id: "t" } + ); + try { + flush(); + expect(state[0]).toBe(value); + stopHydration(); + flush(); + for (let i = 0; i < 30; i++) await Promise.resolve(); + flush(); + expect(seen.length).toBeGreaterThan(0); + for (const leaf of seen) expect(leaf).toBe(value); + expect(state[0]).toBe(next); + } finally { + dispose(); + } + }); + } +}); + +describe("hybrid store takeover", () => { + afterEach(stopHydration); + for (const optimistic of [false, true]) { + test(`waits for the server answer and hydration claim: optimistic=${optimistic}`, async () => { + let resolve!: (value: { count: number }) => void, dispose!: () => void; + const server = new Promise<{ count: number }>(r => { + resolve = r; + }); + startHydration({ t0: server }); + const [state] = createRoot( + d => { + dispose = d; + return (optimistic ? createOptimisticStore : createStore)( + async function* (draft) { + draft.count = 1; + yield; + draft.count = 2; + yield; + }, + { count: 0 }, + { ssrSource: "hybrid" } + ); + }, + { id: "t" } + ); + try { + flush(); + await tick(); + expect(() => state.count).toThrow(NotReadyError); + resolve({ count: 1 }); + await tick(); + expect(state.count).toBe(1); + // More time passing must not let the client supersede this first + // answer before the delayed server fragment has been claimed. + await tick(); + expect(state.count).toBe(1); + stopHydration(); + flush(); + await tick(); + expect(state.count).toBe(2); + } finally { + resolve({ count: 1 }); + dispose(); + } + }); + } +}); + +describe("hybrid loading values and later questions", () => { + afterEach(stopHydration); + for (const optimistic of [false, true]) { + test(`waits for commit one and keeps later first yields: optimistic=${optimistic}`, async () => { + let resolve!: (value: { count: number }) => void, dispose!: () => void; + const server = new Promise<{ count: number }>(r => { + resolve = r; + }); + startHydration({ t0: server }); + const [version, update] = createSignal(1); + const [state] = createRoot( + d => { + dispose = d; + return (optimistic ? createOptimisticStore : createStore)( + async function* (draft) { + draft.count = version(); + yield; + }, + { count: 0 }, + { ssrSource: "hybrid", seedLoadingValue: true } + ); + }, + { id: "t" } + ); + try { + expect(state.count).toBe(0); + stopHydration(); + flush(); + await tick(); + expect(state.count).toBe(0); + resolve({ count: 1 }); + await tick(); + await tick(); + expect(state.count).toBe(1); + update(2); + flush(); + await tick(); + expect(state.count).toBe(2); + } finally { + resolve({ count: 1 }); + dispose(); + } + }); + } +}); + +describe("hybrid rejected adoption", () => { + afterEach(stopHydration); + test("keeps the server error visible and permits a later explicit refresh", async () => { + let reject!: (error: Error) => void, dispose!: () => void; + const server = new Promise<{ count: number }>((_, r) => { + reject = r; + }); + startHydration({ t0: server }); + const [state] = createRoot( + d => { + dispose = d; + return createStore( + async function* (draft) { + draft.count = 2; + yield; + }, + { count: 0 }, + { ssrSource: "hybrid" } + ); + }, + { id: "t" } + ); + try { + const error = new Error("server failed"); + reject(error); + await tick(); + expect(() => state.count).toThrow(error.message); + stopHydration(); + flush(); + await tick(); + expect(() => state.count).toThrow(error.message); + await refresh(state); + await tick(); + expect(state.count).toBe(2); + } finally { + dispose(); + } + }); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index c80b0a3ff..eac256d79 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -520,7 +520,9 @@ module.exports = [ // (+1 over the cap); +100 B minified in the signals floor (24,478 -> 24,578). // Hold-consistency batch 2 (#3479, 2026-09-15): measured at 16,343 B; the signals // core delta, see the core floor note. - limit: "16.45 KB", + // Shallow projection correctness (#3498): 16,366 -> 16,470 B locally. + // Preserve raw draft leaves and shallow loading snapshots. + limit: "16.47 KB", modifyEsbuildConfig }, { @@ -881,7 +883,8 @@ module.exports = [ // ~+200 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "19.80 KB", + // Shallow shadows and hybrid handoff (#3498): 19,782 -> 19,853 B locally. + limit: "19.86 KB", modifyEsbuildConfig }, { @@ -1063,7 +1066,8 @@ module.exports = [ // ~+250 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "29.90 KB", + // Shallow projections and hybrid handoff (#3498): 29,854 -> 29,952 B locally. + limit: "29.96 KB", modifyEsbuildConfig }, { @@ -1161,7 +1165,8 @@ module.exports = [ // ~+180 B; pay-for-use, the price of a boundary that can tell a monitor // what it caught. Scenarios without a boundary did not move (`render`'s // write of `onError` onto the root owner is the only prod-floor cost). - limit: "15.15 KB", + // Store/hydration correctness (#3498): 15,140 -> 15,180 B locally. + limit: "15.18 KB", modifyEsbuildConfig }, {