authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:14:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:14:46-07:00
log9a78c6f2bab95bdb899dffcefd50fa52f7b61c07
treea3d48f1f49fe05b98c15766710ccbc7888c3cd61
parentaab72f10c1227b7bbf9cce76644e374e275a4e1e
signature Signed by PGP key B5690EEEBB952194

[codex] Structure web local storage failures (#3350)

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 237 insertions(+), 38 deletions(-)

apps/web/src/clientPersistenceStorage.test.ts+18
......@@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => {
5151
5252 expect(readBrowserClientSettings()).toEqual(settings);
5353 });
54
55 it("reports structured decode failures while preserving the fallback", async () => {
56 const testWindow = getTestWindow();
57 testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json");
58 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
59 const { readBrowserClientSettings } = await import("./clientPersistenceStorage");
60
61 expect(readBrowserClientSettings()).toBeNull();
62 expect(consoleError).toHaveBeenCalledWith(
63 "Could not read persisted client settings.",
64 expect.objectContaining({
65 _tag: "LocalStorageOperationError",
66 operation: "decode",
67 storageKey: "t3code:client-settings:v1",
68 cause: expect.anything(),
69 }),
70 );
71 });
5472});
apps/web/src/clientPersistenceStorage.ts+2-1
......@@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null {
1515
1616 try {
1717 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
18 } catch {
18 } catch (error) {
19 console.error("Could not read persisted client settings.", error);
1920 return null;
2021 }
2122}
apps/web/src/components/files/FilePreviewPanel.tsx+9-4
......@@ -12,12 +12,14 @@ import {
1212 squashAtomCommandFailure,
1313} from "@t3tools/client-runtime/state/runtime";
1414import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
15import * as Schema from "effect/Schema";
1516import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1617
1718import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
1819import ChatMarkdown from "~/components/ChatMarkdown";
1920import { OpenInPicker } from "~/components/chat/OpenInPicker";
2021import { useTheme } from "~/hooks/useTheme";
22import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
2123import { resolveDiffThemeName } from "~/lib/diffRendering";
2224import { cn } from "~/lib/utils";
2325import { isPreviewSupportedInRuntime } from "~/previewStateStore";
......@@ -589,8 +591,9 @@ function RenderedMarkdownSurface({
589591
590592function initialExplorerOpen(): boolean {
591593 try {
592 return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false";
593 } catch {
594 return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true;
595 } catch (error) {
596 console.error(error);
594597 return true;
595598 }
596599}
......@@ -650,8 +653,10 @@ export default function FilePreviewPanel({
650653 setExplorerOpen((current) => {
651654 const next = !current;
652655 try {
653 window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next));
654 } catch {}
656 setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean);
657 } catch (error) {
658 console.error(error);
659 }
655660 return next;
656661 });
657662 };
apps/web/src/hooks/useLocalStorage.test.ts created+121
......@@ -0,0 +1,121 @@
1import * as Schema from "effect/Schema";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4function createStorage(overrides: Partial<Storage> = {}): Storage {
5 const store = new Map<string, string>();
6 return {
7 clear: () => store.clear(),
8 getItem: (key) => store.get(key) ?? null,
9 key: (index) => [...store.keys()][index] ?? null,
10 get length() {
11 return store.size;
12 },
13 removeItem: (key) => {
14 store.delete(key);
15 },
16 setItem: (key, value) => {
17 store.set(key, value);
18 },
19 ...overrides,
20 };
21}
22
23async function loadWithStorage(storage: Storage) {
24 vi.stubGlobal("window", { localStorage: storage });
25 vi.stubGlobal("localStorage", storage);
26 return import("./useLocalStorage");
27}
28
29afterEach(() => {
30 vi.resetModules();
31 vi.unstubAllGlobals();
32});
33
34describe("local storage errors", () => {
35 it("preserves read failure context", async () => {
36 const cause = new Error("storage unavailable");
37 const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage(
38 createStorage({
39 getItem: () => {
40 throw cause;
41 },
42 }),
43 );
44
45 try {
46 getLocalStorageItem("read-key", Schema.String);
47 expect.unreachable("expected the read to fail");
48 } catch (error) {
49 expect(error).toBeInstanceOf(LocalStorageOperationError);
50 expect(error).toMatchObject({
51 operation: "read",
52 storageKey: "read-key",
53 cause,
54 });
55 }
56 });
57
58 it("preserves decode failure context", async () => {
59 const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage(
60 createStorage({ getItem: () => "not-json" }),
61 );
62
63 try {
64 getLocalStorageItem("decode-key", Schema.String);
65 expect.unreachable("expected decoding to fail");
66 } catch (error) {
67 expect(error).toBeInstanceOf(LocalStorageOperationError);
68 expect(error).toMatchObject({
69 operation: "decode",
70 storageKey: "decode-key",
71 cause: expect.anything(),
72 });
73 }
74 });
75
76 it("preserves write failure context", async () => {
77 const cause = new Error("storage quota exceeded");
78 const { LocalStorageOperationError, setLocalStorageItem } = await loadWithStorage(
79 createStorage({
80 setItem: () => {
81 throw cause;
82 },
83 }),
84 );
85
86 try {
87 setLocalStorageItem("write-key", "value", Schema.String);
88 expect.unreachable("expected the write to fail");
89 } catch (error) {
90 expect(error).toBeInstanceOf(LocalStorageOperationError);
91 expect(error).toMatchObject({
92 operation: "write",
93 storageKey: "write-key",
94 cause,
95 });
96 }
97 });
98
99 it("preserves removal failure context", async () => {
100 const cause = new Error("storage unavailable");
101 const { LocalStorageOperationError, removeLocalStorageItem } = await loadWithStorage(
102 createStorage({
103 removeItem: () => {
104 throw cause;
105 },
106 }),
107 );
108
109 try {
110 removeLocalStorageItem("remove-key");
111 expect.unreachable("expected the removal to fail");
112 } catch (error) {
113 expect(error).toBeInstanceOf(LocalStorageOperationError);
114 expect(error).toMatchObject({
115 operation: "remove",
116 storageKey: "remove-key",
117 cause,
118 });
119 }
120 });
121});
apps/web/src/hooks/useLocalStorage.ts+75-24
......@@ -2,6 +2,19 @@ import * as Schema from "effect/Schema";
22import * as Record from "effect/Record";
33import { useCallback, useMemo, useSyncExternalStore } from "react";
44
5export class LocalStorageOperationError extends Schema.TaggedErrorClass<LocalStorageOperationError>()(
6 "LocalStorageOperationError",
7 {
8 operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]),
9 storageKey: Schema.String,
10 cause: Schema.Defect(),
11 },
12) {
13 override get message(): string {
14 return `Failed to ${this.operation} local storage item ${this.storageKey}.`;
15 }
16}
17
518const isomorphicLocalStorage: Storage =
619 typeof window !== "undefined"
720 ? window.localStorage
......@@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage =
1932 };
2033 })();
2134
22const decode = <T, E>(schema: Schema.Codec<T, E>, value: string) => {
23 const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema));
24 return decodeJson(value);
35const read = (key: string) => {
36 try {
37 return isomorphicLocalStorage.getItem(key);
38 } catch (cause) {
39 throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause });
40 }
41};
42
43const decode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: string) => {
44 try {
45 return Schema.decodeSync(Schema.fromJsonString(schema))(value);
46 } catch (cause) {
47 throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause });
48 }
2549};
2650
27const encode = <T, E>(schema: Schema.Codec<T, E>, value: T) => {
28 const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema));
29 return encodeJson(value);
51const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
52 try {
53 return Schema.encodeSync(Schema.fromJsonString(schema))(value);
54 } catch (cause) {
55 throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause });
56 }
3057};
3158
3259export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
33 const item = isomorphicLocalStorage.getItem(key);
34 return item ? decode(schema, item) : null;
60 const item = read(key);
61 return item ? decode(key, schema, item) : null;
3562};
3663
3764export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
38 const valueToSet = encode(schema, value);
39 isomorphicLocalStorage.setItem(key, valueToSet);
65 const valueToSet = encode(key, schema, value);
66 try {
67 isomorphicLocalStorage.setItem(key, valueToSet);
68 } catch (cause) {
69 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });
70 }
4071};
4172
4273export const removeLocalStorageItem = (key: string) => {
43 isomorphicLocalStorage.removeItem(key);
74 try {
75 isomorphicLocalStorage.removeItem(key);
76 } catch (cause) {
77 throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause });
78 }
4479};
4580
4681const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
......@@ -51,11 +86,15 @@ interface LocalStorageChangeDetail {
5186
5287function dispatchLocalStorageChange(key: string) {
5388 if (typeof window === "undefined") return;
54 window.dispatchEvent(
55 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
56 detail: { key },
57 }),
58 );
89 try {
90 window.dispatchEvent(
91 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
92 detail: { key },
93 }),
94 );
95 } catch (cause) {
96 throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause });
97 }
5998}
6099
61100export function useLocalStorage<T, E>(
......@@ -65,9 +104,9 @@ export function useLocalStorage<T, E>(
65104): [T, (value: T | ((val: T) => T)) => void] {
66105 const getSnapshot = useCallback(() => {
67106 try {
68 return isomorphicLocalStorage.getItem(key);
107 return read(key);
69108 } catch (error) {
70 console.error("[LOCALSTORAGE] Error:", error);
109 console.error("[LOCALSTORAGE] Could not read stored value.", error);
71110 return null;
72111 }
73112 }, [key]);
......@@ -101,19 +140,31 @@ export function useLocalStorage<T, E>(
101140 return initialValue;
102141 }
103142 try {
104 return decode(schema, serializedValue);
143 return decode(key, schema, serializedValue);
105144 } catch (error) {
106 console.error("[LOCALSTORAGE] Error:", error);
145 console.error("[LOCALSTORAGE] Could not decode stored value.", error);
107146 return initialValue;
108147 }
109 }, [initialValue, schema, serializedValue]);
148 }, [initialValue, key, schema, serializedValue]);
110149
111150 const setValue = useCallback(
112151 (value: T | ((val: T) => T)) => {
113152 try {
114153 const currentValue = getLocalStorageItem(key, schema) ?? initialValue;
115 const valueToStore =
116 typeof value === "function" ? (value as (val: T) => T)(currentValue) : value;
154 let valueToStore: T;
155 if (typeof value === "function") {
156 try {
157 valueToStore = (value as (val: T) => T)(currentValue);
158 } catch (cause) {
159 throw new LocalStorageOperationError({
160 operation: "update",
161 storageKey: key,
162 cause,
163 });
164 }
165 } else {
166 valueToStore = value;
167 }
117168 if (valueToStore === null) {
118169 removeLocalStorageItem(key);
119170 } else {
......@@ -121,7 +172,7 @@ export function useLocalStorage<T, E>(
121172 }
122173 dispatchLocalStorageChange(key);
123174 } catch (error) {
124 console.error("[LOCALSTORAGE] Error:", error);
175 console.error("[LOCALSTORAGE] Could not update stored value.", error);
125176 }
126177 },
127178 [initialValue, key, schema],
apps/web/src/hooks/useResizableWidth.ts+4-3
......@@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
5555 try {
5656 const stored = getLocalStorageItem(storageKey, WidthSchema);
5757 return clamp(stored ?? defaultWidth);
58 } catch {
58 } catch (error) {
59 console.error("Could not read persisted panel width.", error);
5960 return defaultWidth;
6061 }
6162 });
......@@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
141142 // Commit once at drag-end to avoid 60Hz localStorage writes.
142143 try {
143144 setLocalStorageItem(storageKey, finalWidth, WidthSchema);
144 } catch {
145 // localStorage may be full / disabled; the in-memory state still wins.
145 } catch (error) {
146 console.error("Could not persist panel width.", error);
146147 }
147148 setWidth(finalWidth);
148149 },
apps/web/src/providerUpdateDismissal.ts+4-3
......@@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals {
2121 keys: [],
2222 }
2323 );
24 } catch {
24 } catch (error) {
25 console.error("Could not read provider-update dismissals.", error);
2526 return { keys: [] };
2627 }
2728}
......@@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void
3334 document,
3435 ProviderUpdateDismissalsSchema,
3536 );
36 } catch {
37 // Dismissal state is best-effort UI state; a storage failure should not block the toast.
37 } catch (error) {
38 console.error("Could not persist provider-update dismissals.", error);
3839 }
3940}
4041
apps/web/src/versionSkew.ts+4-3
......@@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals {
6464 VersionMismatchDismissalsSchema,
6565 ) ?? { keys: [] }
6666 );
67 } catch {
67 } catch (error) {
68 console.error("Could not read version-mismatch dismissals.", error);
6869 return { keys: [] };
6970 }
7071}
......@@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo
7677 document,
7778 VersionMismatchDismissalsSchema,
7879 );
79 } catch {
80 // Dismissal state is best-effort UI state; a storage failure should not block the banner.
80 } catch (error) {
81 console.error("Could not persist version-mismatch dismissals.", error);
8182 }
8283}
8384