From 8e44da7a4cc4c8861dba38d6e7bd8344c1a5e474 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:21:58 +0000 Subject: [PATCH 1/2] fix(state): read a stored value that is not JSON as nothing stored `JSON.parse` threw in the `getItem` of both adapters, and `persistSignals` then stopped the entry. So `isHydrated` stayed `false` and the signal was never persisted again. The bad value stayed in storage, and every reload stopped at the same point. Both adapters now read such a value as `undefined`, which the hydration already treats as "nothing stored". Closes #236. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- .../createLocalStorageAdapter.test.ts | 27 +++++++++++++++++++ .../adapters/createLocalStorageAdapter.ts | 9 ++----- .../adapters/createLocalforageAdapter.test.ts | 13 +++++++++ .../adapters/createLocalforageAdapter.ts | 9 ++----- .../state/persistence/adapters/parseJson.ts | 13 +++++++++ 5 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts create mode 100644 src/lib/state/persistence/adapters/createLocalforageAdapter.test.ts create mode 100644 src/lib/state/persistence/adapters/parseJson.ts diff --git a/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts b/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts new file mode 100644 index 00000000..81557535 --- /dev/null +++ b/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from "vitest" +import { createLocalStorageAdapter } from "./createLocalStorageAdapter" + +afterEach(() => { + localStorage.clear() +}) + +describe("createLocalStorageAdapter", () => { + it("should read a value that is not JSON as nothing stored", async () => { + localStorage.setItem("theme", "dark") + + expect(await createLocalStorageAdapter().getItem("theme")).toBe(undefined) + }) + + describe("with a shared key", () => { + it("should read a shared entry that is not JSON as an empty store", async () => { + localStorage.setItem("app", "not json") + const adapter = createLocalStorageAdapter({ key: "app" }) + + expect(await adapter.getItem("theme")).toBe(undefined) + + await adapter.setItem("theme", "dark") + + expect(await adapter.getItem("theme")).toBe("dark") + }) + }) +}) diff --git a/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts b/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts index af357453..ed40c199 100644 --- a/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts +++ b/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts @@ -1,4 +1,5 @@ import type { Adapter } from "./Adapter" +import { parseJson } from "./parseJson" const normalizeStore = (store: unknown) => { if (!store || typeof store !== "object") { @@ -73,13 +74,7 @@ export const createLocalStorageAdapter = ({ localStorage.removeItem(key) }, - getItem: async (key) => { - const serializedValue = localStorage.getItem(key) - - if (!serializedValue) return undefined - - return JSON.parse(serializedValue) - }, + getItem: async (key) => parseJson(localStorage.getItem(key)), setItem: async (key, value) => { localStorage.setItem(key, JSON.stringify(value)) diff --git a/src/lib/state/persistence/adapters/createLocalforageAdapter.test.ts b/src/lib/state/persistence/adapters/createLocalforageAdapter.test.ts new file mode 100644 index 00000000..ff03282c --- /dev/null +++ b/src/lib/state/persistence/adapters/createLocalforageAdapter.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest" +import { createLocalforageAdapter } from "./createLocalforageAdapter" + +describe("createLocalforageAdapter", () => { + it("should read a value that is not JSON as nothing stored", async () => { + const adapter = createLocalforageAdapter({ + getItem: async () => "dark", + setItem: async (_, value) => value, + }) + + expect(await adapter.getItem("theme")).toBe(undefined) + }) +}) diff --git a/src/lib/state/persistence/adapters/createLocalforageAdapter.ts b/src/lib/state/persistence/adapters/createLocalforageAdapter.ts index 5f903cdd..ab55c4d0 100644 --- a/src/lib/state/persistence/adapters/createLocalforageAdapter.ts +++ b/src/lib/state/persistence/adapters/createLocalforageAdapter.ts @@ -1,16 +1,11 @@ import type { Adapter } from "./Adapter" +import { parseJson } from "./parseJson" export const createLocalforageAdapter = (forage: { getItem: (key: string) => Promise setItem: (key: string, value: string) => Promise }): Adapter => ({ - getItem: async (key: string) => { - const serializedValue = await forage.getItem(key) - - if (!serializedValue) return undefined - - return JSON.parse(serializedValue) - }, + getItem: async (key: string) => parseJson(await forage.getItem(key)), setItem: async (key: string, value: unknown) => { await forage.setItem(key, JSON.stringify(value)) diff --git a/src/lib/state/persistence/adapters/parseJson.ts b/src/lib/state/persistence/adapters/parseJson.ts new file mode 100644 index 00000000..9d4b4267 --- /dev/null +++ b/src/lib/state/persistence/adapters/parseJson.ts @@ -0,0 +1,13 @@ +/** + * Parses a stored value. A value that is not JSON counts as nothing stored, + * so it does not block the hydration. + */ +export const parseJson = (serializedValue: string | null): unknown => { + if (!serializedValue) return undefined + + try { + return JSON.parse(serializedValue) + } catch { + return undefined + } +} From d74b13380e2bc9982cad8a89259ddeccfab096c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:22:43 +0000 Subject: [PATCH 2/2] fix(state): keep overlapping writes to a shared localStorage entry `createLocalStorageAdapter({ key })` keeps all signals in one entry. Each call read the entry, waited, then wrote a changed copy. A second call read the same old entry during that wait, so the last write won. Two persisted signals that changed in the same handler lost one value. Each call now reads and writes the entry synchronously, with no `await` in between. Closes #233. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- .../createLocalStorageAdapter.test.ts | 20 +++++++ .../adapters/createLocalStorageAdapter.ts | 56 ++++++++----------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts b/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts index 81557535..8d79d3d6 100644 --- a/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts +++ b/src/lib/state/persistence/adapters/createLocalStorageAdapter.test.ts @@ -13,6 +13,26 @@ describe("createLocalStorageAdapter", () => { }) describe("with a shared key", () => { + it("should keep both of two overlapping setItem calls", async () => { + const adapter = createLocalStorageAdapter({ key: "app" }) + + await Promise.all([adapter.setItem("a", 1), adapter.setItem("b", 2)]) + + expect(JSON.parse(localStorage.getItem("app") ?? "")).toEqual({ + a: 1, + b: 2, + }) + }) + + it("should apply both of an overlapping setItem and removeItem", async () => { + const adapter = createLocalStorageAdapter({ key: "app" }) + await adapter.setItem("a", 1) + + await Promise.all([adapter.setItem("b", 2), adapter.removeItem("a")]) + + expect(JSON.parse(localStorage.getItem("app") ?? "")).toEqual({ b: 2 }) + }) + it("should read a shared entry that is not JSON as an empty store", async () => { localStorage.setItem("app", "not json") const adapter = createLocalStorageAdapter({ key: "app" }) diff --git a/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts b/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts index ed40c199..a73cd073 100644 --- a/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts +++ b/src/lib/state/persistence/adapters/createLocalStorageAdapter.ts @@ -1,55 +1,48 @@ import type { Adapter } from "./Adapter" import { parseJson } from "./parseJson" -const normalizeStore = (store: unknown) => { - if (!store || typeof store !== "object") { - return undefined - } +type Store = Record + +const isStore = (value: unknown): value is Store => + typeof value === "object" && value !== null + +const readStore = (key: string): Store => { + const store = parseJson(localStorage.getItem(key)) + + return isStore(store) ? store : {} +} - return store +const writeStore = (key: string, store: Store) => { + localStorage.setItem(key, JSON.stringify(store)) } /** * Create an adapter which use one unique store entry to store all * state. When using many signals it can help with maintenance to keep things * tidy. + * + * Each call reads and writes the entry synchronously, with no `await` in + * between, so two calls that overlap do not lose a write. */ -const createSharedStoreAdapter = ({ - adapter, - key, -}: { - adapter: Adapter - key: string -}): Adapter => ({ +const createSharedStoreAdapter = (key: string): Adapter => ({ clear: async () => { - return await adapter.removeItem(key) + localStorage.removeItem(key) }, removeItem: async (keyToDelete) => { - const unsafeStore = await adapter.getItem(key) - const { [keyToDelete]: toRemove, ...rest } = - // biome-ignore lint/suspicious/noExplicitAny: TODO - normalizeStore(unsafeStore) ?? ({} as any) + const { [keyToDelete]: _removed, ...rest } = readStore(key) - await adapter.setItem(key, rest) + writeStore(key, rest) }, getItem: async (itemKey: string) => { - const unsafeStore = await adapter.getItem(key) - const store = normalizeStore(unsafeStore) ?? {} + const store = readStore(key) - if (itemKey in store) { - return store[itemKey as keyof typeof store] - } - - return undefined + return Object.hasOwn(store, itemKey) ? store[itemKey] : undefined }, setItem: async (itemKey: string, value: unknown) => { - const unsafeStore = await adapter.getItem(key) - const store = normalizeStore(unsafeStore) ?? {} - - await adapter.setItem(key, { ...store, [itemKey]: value }) + writeStore(key, { ...readStore(key), [itemKey]: value }) }, }) @@ -59,10 +52,7 @@ export const createLocalStorageAdapter = ({ key?: string } = {}): Adapter => { if (key) { - return createSharedStoreAdapter({ - adapter: createLocalStorageAdapter(), - key, - }) + return createSharedStoreAdapter(key) } return {