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", () => {...@@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => {
5151
52 expect(readBrowserClientSettings()).toEqual(settings);52 expect(readBrowserClientSettings()).toEqual(settings);
53 });53 });
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 });
54});72});
apps/web/src/clientPersistenceStorage.ts+2-1
...@@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null {...@@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null {
1515
16 try {16 try {
17 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);17 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
18 } catch {18 } catch (error) {
19 console.error("Could not read persisted client settings.", error);
19 return null;20 return null;
20 }21 }
21}22}
apps/web/src/components/files/FilePreviewPanel.tsx+9-4
...@@ -12,12 +12,14 @@ import {...@@ -12,12 +12,14 @@ import {
12 squashAtomCommandFailure,12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";13} from "@t3tools/client-runtime/state/runtime";
14import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";14import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
15import * as Schema from "effect/Schema";
15import { useCallback, useEffect, useMemo, useRef, useState } from "react";16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1617
17import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";18import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
18import ChatMarkdown from "~/components/ChatMarkdown";19import ChatMarkdown from "~/components/ChatMarkdown";
19import { OpenInPicker } from "~/components/chat/OpenInPicker";20import { OpenInPicker } from "~/components/chat/OpenInPicker";
20import { useTheme } from "~/hooks/useTheme";21import { useTheme } from "~/hooks/useTheme";
22import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
21import { resolveDiffThemeName } from "~/lib/diffRendering";23import { resolveDiffThemeName } from "~/lib/diffRendering";
22import { cn } from "~/lib/utils";24import { cn } from "~/lib/utils";
23import { isPreviewSupportedInRuntime } from "~/previewStateStore";25import { isPreviewSupportedInRuntime } from "~/previewStateStore";
...@@ -589,8 +591,9 @@ function RenderedMarkdownSurface({...@@ -589,8 +591,9 @@ function RenderedMarkdownSurface({
589591
590function initialExplorerOpen(): boolean {592function initialExplorerOpen(): boolean {
591 try {593 try {
592 return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false";594 return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true;
593 } catch {595 } catch (error) {
596 console.error(error);
594 return true;597 return true;
595 }598 }
596}599}
...@@ -650,8 +653,10 @@ export default function FilePreviewPanel({...@@ -650,8 +653,10 @@ export default function FilePreviewPanel({
650 setExplorerOpen((current) => {653 setExplorerOpen((current) => {
651 const next = !current;654 const next = !current;
652 try {655 try {
653 window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next));656 setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean);
654 } catch {}657 } catch (error) {
658 console.error(error);
659 }
655 return next;660 return next;
656 });661 });
657 };662 };
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";...@@ -2,6 +2,19 @@ import * as Schema from "effect/Schema";
2import * as Record from "effect/Record";2import * as Record from "effect/Record";
3import { useCallback, useMemo, useSyncExternalStore } from "react";3import { 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
5const isomorphicLocalStorage: Storage =18const isomorphicLocalStorage: Storage =
6 typeof window !== "undefined"19 typeof window !== "undefined"
7 ? window.localStorage20 ? window.localStorage
...@@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage =...@@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage =
19 };32 };
20 })();33 })();
2134
22const decode = <T, E>(schema: Schema.Codec<T, E>, value: string) => {35const read = (key: string) => {
23 const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema));36 try {
24 return decodeJson(value);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 }
25};49};
2650
27const encode = <T, E>(schema: Schema.Codec<T, E>, value: T) => {51const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
28 const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema));52 try {
29 return encodeJson(value);53 return Schema.encodeSync(Schema.fromJsonString(schema))(value);
54 } catch (cause) {
55 throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause });
56 }
30};57};
3158
32export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {59export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
33 const item = isomorphicLocalStorage.getItem(key);60 const item = read(key);
34 return item ? decode(schema, item) : null;61 return item ? decode(key, schema, item) : null;
35};62};
3663
37export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {64export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
38 const valueToSet = encode(schema, value);65 const valueToSet = encode(key, schema, value);
39 isomorphicLocalStorage.setItem(key, valueToSet);66 try {
67 isomorphicLocalStorage.setItem(key, valueToSet);
68 } catch (cause) {
69 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });
70 }
40};71};
4172
42export const removeLocalStorageItem = (key: string) => {73export 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 }
44};79};
4580
46const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";81const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
...@@ -51,11 +86,15 @@ interface LocalStorageChangeDetail {...@@ -51,11 +86,15 @@ interface LocalStorageChangeDetail {
5186
52function dispatchLocalStorageChange(key: string) {87function dispatchLocalStorageChange(key: string) {
53 if (typeof window === "undefined") return;88 if (typeof window === "undefined") return;
54 window.dispatchEvent(89 try {
55 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {90 window.dispatchEvent(
56 detail: { key },91 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
57 }),92 detail: { key },
58 );93 }),
94 );
95 } catch (cause) {
96 throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause });
97 }
59}98}
6099
61export function useLocalStorage<T, E>(100export function useLocalStorage<T, E>(
...@@ -65,9 +104,9 @@ export function useLocalStorage<T, E>(...@@ -65,9 +104,9 @@ export function useLocalStorage<T, E>(
65): [T, (value: T | ((val: T) => T)) => void] {104): [T, (value: T | ((val: T) => T)) => void] {
66 const getSnapshot = useCallback(() => {105 const getSnapshot = useCallback(() => {
67 try {106 try {
68 return isomorphicLocalStorage.getItem(key);107 return read(key);
69 } catch (error) {108 } catch (error) {
70 console.error("[LOCALSTORAGE] Error:", error);109 console.error("[LOCALSTORAGE] Could not read stored value.", error);
71 return null;110 return null;
72 }111 }
73 }, [key]);112 }, [key]);
...@@ -101,19 +140,31 @@ export function useLocalStorage<T, E>(...@@ -101,19 +140,31 @@ export function useLocalStorage<T, E>(
101 return initialValue;140 return initialValue;
102 }141 }
103 try {142 try {
104 return decode(schema, serializedValue);143 return decode(key, schema, serializedValue);
105 } catch (error) {144 } catch (error) {
106 console.error("[LOCALSTORAGE] Error:", error);145 console.error("[LOCALSTORAGE] Could not decode stored value.", error);
107 return initialValue;146 return initialValue;
108 }147 }
109 }, [initialValue, schema, serializedValue]);148 }, [initialValue, key, schema, serializedValue]);
110149
111 const setValue = useCallback(150 const setValue = useCallback(
112 (value: T | ((val: T) => T)) => {151 (value: T | ((val: T) => T)) => {
113 try {152 try {
114 const currentValue = getLocalStorageItem(key, schema) ?? initialValue;153 const currentValue = getLocalStorageItem(key, schema) ?? initialValue;
115 const valueToStore =154 let valueToStore: T;
116 typeof value === "function" ? (value as (val: T) => T)(currentValue) : value;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 }
117 if (valueToStore === null) {168 if (valueToStore === null) {
118 removeLocalStorageItem(key);169 removeLocalStorageItem(key);
119 } else {170 } else {
...@@ -121,7 +172,7 @@ export function useLocalStorage<T, E>(...@@ -121,7 +172,7 @@ export function useLocalStorage<T, E>(
121 }172 }
122 dispatchLocalStorageChange(key);173 dispatchLocalStorageChange(key);
123 } catch (error) {174 } catch (error) {
124 console.error("[LOCALSTORAGE] Error:", error);175 console.error("[LOCALSTORAGE] Could not update stored value.", error);
125 }176 }
126 },177 },
127 [initialValue, key, schema],178 [initialValue, key, schema],
apps/web/src/hooks/useResizableWidth.ts+4-3
...@@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
55 try {55 try {
56 const stored = getLocalStorageItem(storageKey, WidthSchema);56 const stored = getLocalStorageItem(storageKey, WidthSchema);
57 return clamp(stored ?? defaultWidth);57 return clamp(stored ?? defaultWidth);
58 } catch {58 } catch (error) {
59 console.error("Could not read persisted panel width.", error);
59 return defaultWidth;60 return defaultWidth;
60 }61 }
61 });62 });
...@@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
141 // Commit once at drag-end to avoid 60Hz localStorage writes.142 // Commit once at drag-end to avoid 60Hz localStorage writes.
142 try {143 try {
143 setLocalStorageItem(storageKey, finalWidth, WidthSchema);144 setLocalStorageItem(storageKey, finalWidth, WidthSchema);
144 } catch {145 } catch (error) {
145 // localStorage may be full / disabled; the in-memory state still wins.146 console.error("Could not persist panel width.", error);
146 }147 }
147 setWidth(finalWidth);148 setWidth(finalWidth);
148 },149 },
apps/web/src/providerUpdateDismissal.ts+4-3
...@@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals {...@@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals {
21 keys: [],21 keys: [],
22 }22 }
23 );23 );
24 } catch {24 } catch (error) {
25 console.error("Could not read provider-update dismissals.", error);
25 return { keys: [] };26 return { keys: [] };
26 }27 }
27}28}
...@@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void...@@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void
33 document,34 document,
34 ProviderUpdateDismissalsSchema,35 ProviderUpdateDismissalsSchema,
35 );36 );
36 } catch {37 } catch (error) {
37 // Dismissal state is best-effort UI state; a storage failure should not block the toast.38 console.error("Could not persist provider-update dismissals.", error);
38 }39 }
39}40}
4041
apps/web/src/versionSkew.ts+4-3
...@@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals {...@@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals {
64 VersionMismatchDismissalsSchema,64 VersionMismatchDismissalsSchema,
65 ) ?? { keys: [] }65 ) ?? { keys: [] }
66 );66 );
67 } catch {67 } catch (error) {
68 console.error("Could not read version-mismatch dismissals.", error);
68 return { keys: [] };69 return { keys: [] };
69 }70 }
70}71}
...@@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo...@@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo
76 document,77 document,
77 VersionMismatchDismissalsSchema,78 VersionMismatchDismissalsSchema,
78 );79 );
79 } catch {80 } catch (error) {
80 // Dismissal state is best-effort UI state; a storage failure should not block the banner.81 console.error("Could not persist version-mismatch dismissals.", error);
81 }82 }
82}83}
8384