History

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

Julius Marminge committed 4 months ago

Commit 9a78c6f1 parent aab72f18 files · +237 −38Browse files

Unified · Split

8 files
  1. apps/web/src/clientPersistenceStorage.test.ts +18 −0
  2. apps/web/src/clientPersistenceStorage.ts +2 −1
  3. apps/web/src/components/files/FilePreviewPanel.tsx +9 −4
  4. apps/web/src/hooks/useLocalStorage.test.ts +121 −0
  5. apps/web/src/hooks/useLocalStorage.ts +75 −24
  6. apps/web/src/hooks/useResizableWidth.ts +4 −3
  7. apps/web/src/providerUpdateDismissal.ts +4 −3
  8. apps/web/src/versionSkew.ts +4 −3
apps/web/src/clientPersistenceStorage.test.ts+18−0
@@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => { · 50 unmodified lines
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 { · 14 unmodified lines
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 { · 11 unmodified lines
1212 squashAtomCommandFailure,
1313 } from "@t3tools/client-runtime/state/runtime";
1414 import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
15+import * as Schema from "effect/Schema";
1516 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1617
1718 import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
1819 import ChatMarkdown from "~/components/ChatMarkdown";
1920 import { OpenInPicker } from "~/components/chat/OpenInPicker";
2021 import { useTheme } from "~/hooks/useTheme";
22+import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
2123 import { resolveDiffThemeName } from "~/lib/diffRendering";
2224 import { cn } from "~/lib/utils";
2325 import { isPreviewSupportedInRuntime } from "~/previewStateStore";
@@ -589,8 +591,9 @@ function RenderedMarkdownSurface({ · 565 unmodified lines
589591
590592 function 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({ · 53 unmodified lines
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+121−0
@@ -0,0 +1,121 @@
1+import * as Schema from "effect/Schema";
2+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3+
4+function 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+
23+async function loadWithStorage(storage: Storage) {
24+ vi.stubGlobal("window", { localStorage: storage });
25+ vi.stubGlobal("localStorage", storage);
26+ return import("./useLocalStorage");
27+}
28+
29+afterEach(() => {
30+ vi.resetModules();
31+ vi.unstubAllGlobals();
32+});
33+
34+describe("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"; · 1 unmodified line
22 import * as Record from "effect/Record";
33 import { useCallback, useMemo, useSyncExternalStore } from "react";
44
5+export 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+
518 const isomorphicLocalStorage: Storage =
619 typeof window !== "undefined"
720 ? window.localStorage
@@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage = · 11 unmodified lines
1932 };
2033 })();
2134
22−const decode = <T, E>(schema: Schema.Codec<T, E>, value: string) => {
23− const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema));
24− return decodeJson(value);
35+const 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+
43+const 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
27−const encode = <T, E>(schema: Schema.Codec<T, E>, value: T) => {
28− const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema));
29− return encodeJson(value);
51+const 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
3259 export 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
3764 export 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
4273 export 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
4681 const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
@@ -51,11 +86,15 @@ interface LocalStorageChangeDetail { · 4 unmodified lines
5186
5287 function 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
61100 export function useLocalStorage<T, E>(
@@ -65,9 +104,9 @@ export function useLocalStorage<T, E>( · 3 unmodified lines
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>( · 27 unmodified lines
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>( · 1 unmodified line
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): { · 54 unmodified lines
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): { · 79 unmodified lines
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 { · 20 unmodified lines
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 · 5 unmodified lines
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 { · 63 unmodified lines
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 · 5 unmodified lines
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