diff --git a/.changeset/client-store-hydration-pending.md b/.changeset/client-store-hydration-pending.md new file mode 100644 index 000000000..bc6cbcf10 --- /dev/null +++ b/.changeset/client-store-hydration-pending.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Keep client-sourced stores pending during hydration until their first client result unless `seedLoadingValue` exposes the seed as commit zero. diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 2e79955e9..9610232e2 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -1129,14 +1129,10 @@ function hydrateStoreLikeFn( return withHydrationGate(hydrated => coreFn( (draft: any) => { - // Windowed (seedLoadingValue): UNASKED — a sync no-op derive would - // close the seed window before the real derive ever runs (see the - // signal gate above). Bare: a no-op derive — stores have no - // uninitialized state (reads always serve state), and without a - // window an UNASKED return would be treated as an async result and - // suspend the projection at creation; the seed shows until the gate - // flips and the real derive runs as a fresh first mount. - if (!hydrated()) return hasLoadingWindow(options) ? UNASKED : undefined; + // Keep client-only stores unasked through hydration. With + // seedLoadingValue the seed is commit #0 and remains readable; + // otherwise the store suspends until its first client result. + if (!hydrated()) return UNASKED; return fn(draft); }, initialValue, diff --git a/packages/solid/test/client-hydration.spec.ts b/packages/solid/test/client-hydration.spec.ts index 06f42868d..7be50624a 100644 --- a/packages/solid/test/client-hydration.spec.ts +++ b/packages/solid/test/client-hydration.spec.ts @@ -1222,7 +1222,7 @@ describe("bare ssrSource 'client' — unasked through the gate, computes after", ).not.toThrow(); }); - test("bare client store: seed visible during hydration, derive runs after the gate", () => { + test("bare client store hides its seed until the client derive completes", () => { startHydration({}); let store: any; createRoot( @@ -1234,8 +1234,7 @@ describe("bare ssrSource 'client' — unasked through the gate, computes after", { name: "seed" }, { ssrSource: "client" } ); - // Gate closed: the derive has not run — the seed is what's there. - expect(store.name).toBe("seed"); + expect(() => store.name).toThrow(NotReadyError); }, { id: "t" } ); @@ -1243,6 +1242,57 @@ describe("bare ssrSource 'client' — unasked through the gate, computes after", flush(); expect(store.name).toBe("computed"); }); + + test("createStore(fn) keeps Loading fallback through the first client flight", async () => { + startHydration({}); + const read = (value: any): any => { + while (typeof value === "function") value = value(); + return value; + }; + + let store: any; + let result: any; + let deriveRan = 0; + let resolveDerive!: (value: { name: string }) => void; + createRoot( + () => { + [store] = createStore<{ name: string }>( + () => { + deriveRan++; + return new Promise(resolve => (resolveDerive = resolve)); + }, + { name: "seed" }, + { ssrSource: "client" } + ); + result = Loading({ + fallback: "loading" as any, + get children() { + return store.name; + } + }); + }, + { id: "t" } + ); + flush(); + + expect(deriveRan).toBe(0); + expect(() => store.name).toThrow(NotReadyError); + expect(read(result)).toBe("loading"); + + stopHydration(); + flush(); + + expect(deriveRan).toBe(1); + expect(() => store.name).toThrow(NotReadyError); + expect(read(result)).toBe("loading"); + + resolveDerive({ name: "landed" }); + await new Promise(resolve => setTimeout(resolve)); + flush(); + + expect(store.name).toBe("landed"); + expect(read(result)).toBe("landed"); + }); }); // The hydration gate must not close the loading window: a sync prev-return