Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-badgers-run.md
Original file line number Diff line number Diff line change
@@ -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`.
70 changes: 69 additions & 1 deletion src/persist-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TRaw> seam (pre-freeze seam decision 1): a
// structured-clone-style backend stores the envelope object natively.
Expand Down Expand Up @@ -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 }>(
Expand Down Expand Up @@ -1458,7 +1526,7 @@ describe("persistSource throttleMs", () => {
throttleMs: 60_000,
retryWrite: () => {
retryCalls++;
return undefined;
return;
},
onError: (error, context) =>
errors.push({
Expand Down
18 changes: 15 additions & 3 deletions src/persist-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export interface PersistOptions<TState, TPersistedState = TState> {
* 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)
Expand Down Expand Up @@ -450,7 +450,19 @@ export function createStorage<S, TRaw = string>(
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 = (
Expand Down Expand Up @@ -559,7 +571,7 @@ function resolveDefaultStorage<TState, TPersistedState>(
options: PersistOptions<TState, TPersistedState>,
): PersistStorage<TPersistedState> | 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.
Expand Down