authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:37:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 01:37:47-07:00
log4f909be2c1a2eb4a88e6a51c8d0ef25e47a027d2
tree44413418a2135e2040b53f077d967a19071b18fc
parent2d14185f32b6c0141a3f1192caf4a8d181cbe10f
signature Signed by PGP key B5690EEEBB952194

fix(web): settings save again after a stored value fails decode (#19312)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 61 insertions(+), 1 deletions(-)

apps/web/src/hooks/useLocalStorage.hook.test.tsx created+53
......@@ -0,0 +1,53 @@
1// @vitest-environment jsdom
2import * as Schema from "effect/Schema";
3import { act, createElement, useEffect } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { useLocalStorage } from "./useLocalStorage";
8
9const KEY = "corrupt-key";
10let root: Root;
11let current: ReturnType<typeof useLocalStorage<number, number>>;
12
13function Probe() {
14 const result = useLocalStorage(KEY, 0, Schema.Number);
15 useEffect(() => {
16 current = result;
17 });
18 return null;
19}
20
21beforeEach(async () => {
22 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
23 vi.spyOn(console, "error").mockImplementation(() => {});
24 // Valid JSON that no longer matches the schema, as after a schema change.
25 localStorage.setItem(KEY, JSON.stringify("not a number"));
26 root = createRoot(document.createElement("div"));
27 await act(() => root.render(createElement(Probe)));
28});
29
30afterEach(async () => {
31 await act(() => root.unmount());
32 localStorage.clear();
33 vi.restoreAllMocks();
34 vi.unstubAllGlobals();
35});
36
37describe("useLocalStorage with a stored value that fails decode", () => {
38 it("replaces it with a plain write", async () => {
39 expect(current[0]).toBe(0);
40
41 await act(() => current[1](42));
42
43 expect(localStorage.getItem(KEY)).toBe("42");
44 expect(current[0]).toBe(42);
45 });
46
47 it("runs an updater from the initial value", async () => {
48 await act(() => current[1]((value) => value + 1));
49
50 expect(localStorage.getItem(KEY)).toBe("1");
51 expect(current[0]).toBe(1);
52 });
53});
apps/web/src/hooks/useLocalStorage.ts+8-1
......@@ -150,9 +150,16 @@ export function useLocalStorage<T, E>(
150150 const setValue = useCallback(
151151 (value: T | ((val: T) => T)) => {
152152 try {
153 const currentValue = getLocalStorageItem(key, schema) ?? initialValue;
154153 let valueToStore: T;
155154 if (typeof value === "function") {
155 // Like the read path, treat an unreadable stored value as the initial
156 // value, so a corrupt blob cannot block every later write.
157 let currentValue = initialValue;
158 try {
159 currentValue = getLocalStorageItem(key, schema) ?? initialValue;
160 } catch (error) {
161 console.error("[LOCALSTORAGE] Could not read stored value.", error);
162 }
156163 try {
157164 valueToStore = (value as (val: T) => T)(currentValue);
158165 } catch (cause) {