From 4f909be2c1a2eb4a88e6a51c8d0ef25e47a027d2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 01:37:47 -0700 Subject: [PATCH] fix(web): settings save again after a stored value fails decode (#19312) Co-authored-by: Claude Opus 5.5 --- .../src/hooks/useLocalStorage.hook.test.tsx | 53 +++++++++++++++++++ apps/web/src/hooks/useLocalStorage.ts | 9 +++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/hooks/useLocalStorage.hook.test.tsx diff --git a/apps/web/src/hooks/useLocalStorage.hook.test.tsx b/apps/web/src/hooks/useLocalStorage.hook.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9921426ef8338f4d5b60d76f68afdd7308ff86fb --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.hook.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import * as Schema from "effect/Schema"; +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useLocalStorage } from "./useLocalStorage"; + +const KEY = "corrupt-key"; +let root: Root; +let current: ReturnType>; + +function Probe() { + const result = useLocalStorage(KEY, 0, Schema.Number); + useEffect(() => { + current = result; + }); + return null; +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(console, "error").mockImplementation(() => {}); + // Valid JSON that no longer matches the schema, as after a schema change. + localStorage.setItem(KEY, JSON.stringify("not a number")); + root = createRoot(document.createElement("div")); + await act(() => root.render(createElement(Probe))); +}); + +afterEach(async () => { + await act(() => root.unmount()); + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("useLocalStorage with a stored value that fails decode", () => { + it("replaces it with a plain write", async () => { + expect(current[0]).toBe(0); + + await act(() => current[1](42)); + + expect(localStorage.getItem(KEY)).toBe("42"); + expect(current[0]).toBe(42); + }); + + it("runs an updater from the initial value", async () => { + await act(() => current[1]((value) => value + 1)); + + expect(localStorage.getItem(KEY)).toBe("1"); + expect(current[0]).toBe(1); + }); +}); diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 8e7158c36152c7a17a8f273632a295134d33088a..7d6c39bdbd25034c5723a4d9422c279f596d6308 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -150,9 +150,16 @@ export function useLocalStorage( const setValue = useCallback( (value: T | ((val: T) => T)) => { try { - const currentValue = getLocalStorageItem(key, schema) ?? initialValue; let valueToStore: T; if (typeof value === "function") { + // Like the read path, treat an unreadable stored value as the initial + // value, so a corrupt blob cannot block every later write. + let currentValue = initialValue; + try { + currentValue = getLocalStorageItem(key, schema) ?? initialValue; + } catch (error) { + console.error("[LOCALSTORAGE] Could not read stored value.", error); + } try { valueToStore = (value as (val: T) => T)(currentValue); } catch (cause) { -- 2.54.0