From 2e9dfb89f630d4a7ced53a1cdebe09d6f2446886 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 13:27:34 +0300 Subject: [PATCH 1/2] fix(persist-core): shape-check storage backend for defined-but-broken globals Node 22+ exposes `localStorage` as an object whose `getItem`/`setItem`/`removeItem` are `undefined` without a valid `--localstorage-file` path. The lookup doesn't throw, so the broken backend passed availability and crashed in `hydrate` at `storage.getItem is not a function` during SSR. `createStorage` now shape-checks the three methods and returns `undefined`, collapsing to the no-op `PersistApi`. --- .changeset/hot-badgers-run.md | 5 +++ src/persist-core.test.ts | 68 +++++++++++++++++++++++++++++++++++ src/persist-core.ts | 12 +++++++ 3 files changed, 85 insertions(+) create mode 100644 .changeset/hot-badgers-run.md diff --git a/.changeset/hot-badgers-run.md b/.changeset/hot-badgers-run.md new file mode 100644 index 0000000..4abbc64 --- /dev/null +++ b/.changeset/hot-badgers-run.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": patch +--- + +Treat a defined-but-broken storage backend (e.g. Node 22+ `localStorage` without a valid `--localstorage-file` path, where the global exists as an object but `getItem`/`setItem`/`removeItem` are `undefined`) as unavailable. `createStorage` now shape-checks the backend and returns `undefined`, so `persistSource`/`persistStore`/`persistAtom` collapse to the no-op `PersistApi` instead of throwing `storage.getItem is not a function` during SSR hydration. diff --git a/src/persist-core.test.ts b/src/persist-core.test.ts index 207b0b8..23876fa 100644 --- a/src/persist-core.test.ts +++ b/src/persist-core.test.ts @@ -148,6 +148,37 @@ describe("createJSONStorage codec seam", () => { ).toBeUndefined(); }); + it("createStorage returns undefined for a defined-but-broken backend (Node 22+ localStorage without --localstorage-file)", () => { + // Node 22+ exposes `localStorage` as an object whose methods are undefined + // when no valid --localstorage-file path is configured. The lookup doesn't + // throw, so the shape check — not the try/catch — must catch it. + const brokenBackend = { + getItem: undefined, + setItem: undefined, + removeItem: undefined, + } as unknown as StateStorage; + expect( + createStorage<{ count: number }>( + () => brokenBackend, + jsonCodec<{ count: number }>(), + ), + ).toBeUndefined(); + }); + + it("createStorage returns undefined when the backend is missing one method", () => { + const partialBackend = { + getItem: () => null, + setItem: () => {}, + // removeItem missing + } as unknown as StateStorage; + expect( + createStorage<{ count: number }>( + () => partialBackend, + jsonCodec<{ count: number }>(), + ), + ).toBeUndefined(); + }); + it("generic wire type: identityCodec over an object-valued backend round-trips with no serialization", async () => { // Pins the StateStorage seam (pre-freeze seam decision 1): a // structured-clone-style backend stores the envelope object natively. @@ -478,6 +509,43 @@ describe("persistSource", () => { } }); + it("defined-but-broken localStorage (Node 22+ without --localstorage-file) collapses to the no-op path, not a hydrate crash", () => { + // Reproduces the SSR crash: `typeof localStorage === "undefined"` is + // false for Node 22+'s half-built global, so without the `createStorage` + // shape check the default JSON storage passes availability and `hydrate` + // throws `storage.getItem is not a function`. Assert the no-op path. + const errors: Array<{ phase: string; message: string }> = []; + const savedLocalStorage = globalThis.localStorage; + // Object present, methods absent; cast through `unknown` — the shape + // check guards this at runtime. + globalThis.localStorage = { + getItem: undefined, + setItem: undefined, + removeItem: undefined, + } as unknown as typeof globalThis.localStorage; + try { + const source = createMockSource({ count: 0 }); + const persist = persistSource(source, { + name: "broken-backend", + onError: (error, context) => + errors.push({ + phase: context.phase, + message: (error as Error).message, + }), + }); + expect(persist.hasHydrated()).toBe(true); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain("storage unavailable"); + } finally { + if (savedLocalStorage === undefined) { + // @ts-expect-error restore the undefined global + delete globalThis.localStorage; + } else { + globalThis.localStorage = savedLocalStorage; + } + } + }); + it("a throwing onFinishHydration listener is contained — reported once, no double-settle, no unhandled rejection", async () => { const memoryStorage = new MemoryStorage(); const jsonStorage = createJSONStorage<{ count: number }>( diff --git a/src/persist-core.ts b/src/persist-core.ts index 0570ab5..2a09e60 100644 --- a/src/persist-core.ts +++ b/src/persist-core.ts @@ -453,6 +453,18 @@ export function createStorage( return undefined; } + // Node 22+ exposes a `localStorage` global whose methods are `undefined` + // without a valid `--localstorage-file` path. The lookup doesn't throw — the + // global exists as an object — so without this shape check the broken + // backend passes availability and crashes in `hydrate` at `storage.getItem`. + if ( + typeof storage?.getItem !== "function" || + typeof storage?.setItem !== "function" || + typeof storage?.removeItem !== "function" + ) { + return undefined; + } + const parseStored = ( name: string, raw: TRaw | null, From 3cc03ba77291033f63898adf8290bb86cfb335b2 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Sat, 4 Jul 2026 13:45:08 +0300 Subject: [PATCH 2/2] style(persist-core): collapse return undefined to bare return Behavior-preserving. Also tightens the changeset to lead with the fix. --- .changeset/hot-badgers-run.md | 2 +- src/persist-core.test.ts | 2 +- src/persist-core.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/hot-badgers-run.md b/.changeset/hot-badgers-run.md index 4abbc64..53c9fbf 100644 --- a/.changeset/hot-badgers-run.md +++ b/.changeset/hot-badgers-run.md @@ -2,4 +2,4 @@ "@stainless-code/persist": patch --- -Treat a defined-but-broken storage backend (e.g. Node 22+ `localStorage` without a valid `--localstorage-file` path, where the global exists as an object but `getItem`/`setItem`/`removeItem` are `undefined`) as unavailable. `createStorage` now shape-checks the backend and returns `undefined`, so `persistSource`/`persistStore`/`persistAtom` collapse to the no-op `PersistApi` instead of throwing `storage.getItem is not a function` during SSR hydration. +`createStorage` now shape-checks the resolved backend and treats one missing `getItem`/`setItem`/`removeItem` as unavailable. Fixes the Node 22+ SSR crash where `localStorage` exists as an object (so the availability lookup doesn't throw) but its methods are `undefined` without a valid `--localstorage-file` path — previously this passed availability and threw `storage.getItem is not a function` inside `hydrate`; now `persistSource`/`persistStore`/`persistAtom` collapse to the no-op `PersistApi`. diff --git a/src/persist-core.test.ts b/src/persist-core.test.ts index 23876fa..00e5a8d 100644 --- a/src/persist-core.test.ts +++ b/src/persist-core.test.ts @@ -1526,7 +1526,7 @@ describe("persistSource throttleMs", () => { throttleMs: 60_000, retryWrite: () => { retryCalls++; - return undefined; + return; }, onError: (error, context) => errors.push({ diff --git a/src/persist-core.ts b/src/persist-core.ts index 2a09e60..37b77dd 100644 --- a/src/persist-core.ts +++ b/src/persist-core.ts @@ -294,7 +294,7 @@ export interface PersistOptions { * retryWrite: ({ state, errorCount }) => { * if (errorCount === 1) return { ...state, history: state.history.slice(-20) }; * if (errorCount === 2) return { ...state, history: [] }; - * return undefined; // give up — last error goes to onError + * return; // give up — last error goes to onError * }, * ``` * @default undefined (no retry — first write error is reported) @@ -450,7 +450,7 @@ export function createStorage( try { storage = getStorage(); } catch { - return undefined; + return; } // Node 22+ exposes a `localStorage` global whose methods are `undefined` @@ -462,7 +462,7 @@ export function createStorage( typeof storage?.setItem !== "function" || typeof storage?.removeItem !== "function" ) { - return undefined; + return; } const parseStored = ( @@ -571,7 +571,7 @@ function resolveDefaultStorage( options: PersistOptions, ): PersistStorage | undefined { if (options.storage) return options.storage; - if (typeof localStorage === "undefined") return undefined; + if (typeof localStorage === "undefined") return; // JSON default keeps the core zero-dep — a seroval default would drag a // runtime dependency into every consumer. `Set`/`Map`/`Date` users opt in // by passing `createSerovalStorage` from `./persist-seroval` explicitly.