diff --git a/.changeset/hot-badgers-run.md b/.changeset/hot-badgers-run.md new file mode 100644 index 0000000..53c9fbf --- /dev/null +++ b/.changeset/hot-badgers-run.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": patch +--- + +`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 207b0b8..00e5a8d 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 }>( @@ -1458,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 0570ab5..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,19 @@ export function createStorage( try { storage = getStorage(); } catch { - return undefined; + return; + } + + // 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; } const parseStored = ( @@ -559,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.