From 1365a132fbebd5c3457fa3a43244d1489fe9e847 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 05:54:40 -0700 Subject: [PATCH 1/3] Structure web local storage failures Co-authored-by: codex --- apps/web/src/clientPersistenceStorage.test.ts | 18 +++ apps/web/src/clientPersistenceStorage.ts | 3 +- apps/web/src/hooks/useLocalStorage.test.ts | 121 ++++++++++++++++++ apps/web/src/hooks/useLocalStorage.ts | 99 ++++++++++---- apps/web/src/providerUpdateDismissal.ts | 7 +- apps/web/src/versionSkew.ts | 7 +- 6 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/hooks/useLocalStorage.test.ts diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 6c449eea2b1..ec335892bea 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); + + it("reports structured decode failures while preserving the fallback", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(readBrowserClientSettings()).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + "Could not read persisted client settings.", + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + cause: expect.anything(), + }), + ); + }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index b6a9f1f8e03..5c0ba7c6ecc 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null { try { return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch { + } catch (error) { + console.error("Could not read persisted client settings.", error); return null; } } diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts new file mode 100644 index 00000000000..27627a36e4b --- /dev/null +++ b/apps/web/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,121 @@ +import * as Schema from "effect/Schema"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +async function loadWithStorage(storage: Storage) { + vi.stubGlobal("window", { localStorage: storage }); + vi.stubGlobal("localStorage", storage); + return import("./useLocalStorage"); +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("local storage errors", () => { + it("preserves read failure context", async () => { + const cause = new Error("storage unavailable"); + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ + getItem: () => { + throw cause; + }, + }), + ); + + try { + getLocalStorageItem("read-key", Schema.String); + expect.unreachable("expected the read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "read-key", + cause, + }); + } + }); + + it("preserves decode failure context", async () => { + const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( + createStorage({ getItem: () => "not-json" }), + ); + + try { + getLocalStorageItem("decode-key", Schema.String); + expect.unreachable("expected decoding to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "decode", + storageKey: "decode-key", + cause: expect.anything(), + }); + } + }); + + it("preserves write failure context", async () => { + const cause = new Error("storage quota exceeded"); + const { LocalStorageOperationError, setLocalStorageItem } = await loadWithStorage( + createStorage({ + setItem: () => { + throw cause; + }, + }), + ); + + try { + setLocalStorageItem("write-key", "value", Schema.String); + expect.unreachable("expected the write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "write-key", + cause, + }); + } + }); + + it("preserves removal failure context", async () => { + const cause = new Error("storage unavailable"); + const { LocalStorageOperationError, removeLocalStorageItem } = await loadWithStorage( + createStorage({ + removeItem: () => { + throw cause; + }, + }), + ); + + try { + removeLocalStorageItem("remove-key"); + expect.unreachable("expected the removal to fail"); + } catch (error) { + expect(error).toBeInstanceOf(LocalStorageOperationError); + expect(error).toMatchObject({ + operation: "remove", + storageKey: "remove-key", + cause, + }); + } + }); +}); diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 50e81dbc0b8..3099e73ff43 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -2,6 +2,19 @@ import * as Schema from "effect/Schema"; import * as Record from "effect/Record"; import { useCallback, useMemo, useSyncExternalStore } from "react"; +export class LocalStorageOperationError extends Schema.TaggedErrorClass()( + "LocalStorageOperationError", + { + operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]), + storageKey: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} local storage item ${this.storageKey}.`; + } +} + const isomorphicLocalStorage: Storage = typeof window !== "undefined" ? window.localStorage @@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage = }; })(); -const decode = (schema: Schema.Codec, value: string) => { - const decodeJson = Schema.decodeSync(Schema.fromJsonString(schema)); - return decodeJson(value); +const read = (key: string) => { + try { + return isomorphicLocalStorage.getItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); + } +}; + +const decode = (key: string, schema: Schema.Codec, value: string) => { + try { + return Schema.decodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause }); + } }; -const encode = (schema: Schema.Codec, value: T) => { - const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema)); - return encodeJson(value); +const encode = (key: string, schema: Schema.Codec, value: T) => { + try { + return Schema.encodeSync(Schema.fromJsonString(schema))(value); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause }); + } }; export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { - const item = isomorphicLocalStorage.getItem(key); - return item ? decode(schema, item) : null; + const item = read(key); + return item ? decode(key, schema, item) : null; }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { - const valueToSet = encode(schema, value); - isomorphicLocalStorage.setItem(key, valueToSet); + const valueToSet = encode(key, schema, value); + try { + isomorphicLocalStorage.setItem(key, valueToSet); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); + } }; export const removeLocalStorageItem = (key: string) => { - isomorphicLocalStorage.removeItem(key); + try { + isomorphicLocalStorage.removeItem(key); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); + } }; const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; @@ -51,11 +86,15 @@ interface LocalStorageChangeDetail { function dispatchLocalStorageChange(key: string) { if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { - detail: { key }, - }), - ); + try { + window.dispatchEvent( + new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { + detail: { key }, + }), + ); + } catch (cause) { + throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause }); + } } export function useLocalStorage( @@ -65,9 +104,9 @@ export function useLocalStorage( ): [T, (value: T | ((val: T) => T)) => void] { const getSnapshot = useCallback(() => { try { - return isomorphicLocalStorage.getItem(key); + return read(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not read stored value.", error); return null; } }, [key]); @@ -101,19 +140,31 @@ export function useLocalStorage( return initialValue; } try { - return decode(schema, serializedValue); + return decode(key, schema, serializedValue); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not decode stored value.", error); return initialValue; } - }, [initialValue, schema, serializedValue]); + }, [initialValue, key, schema, serializedValue]); const setValue = useCallback( (value: T | ((val: T) => T)) => { try { const currentValue = getLocalStorageItem(key, schema) ?? initialValue; - const valueToStore = - typeof value === "function" ? (value as (val: T) => T)(currentValue) : value; + let valueToStore: T; + if (typeof value === "function") { + try { + valueToStore = (value as (val: T) => T)(currentValue); + } catch (cause) { + throw new LocalStorageOperationError({ + operation: "update", + storageKey: key, + cause, + }); + } + } else { + valueToStore = value; + } if (valueToStore === null) { removeLocalStorageItem(key); } else { @@ -121,7 +172,7 @@ export function useLocalStorage( } dispatchLocalStorageChange(key); } catch (error) { - console.error("[LOCALSTORAGE] Error:", error); + console.error("[LOCALSTORAGE] Could not update stored value.", error); } }, [initialValue, key, schema], diff --git a/apps/web/src/providerUpdateDismissal.ts b/apps/web/src/providerUpdateDismissal.ts index 7cce819ccf9..28789152b34 100644 --- a/apps/web/src/providerUpdateDismissal.ts +++ b/apps/web/src/providerUpdateDismissal.ts @@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals { keys: [], } ); - } catch { + } catch (error) { + console.error("Could not read provider-update dismissals.", error); return { keys: [] }; } } @@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void document, ProviderUpdateDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the toast. + } catch (error) { + console.error("Could not persist provider-update dismissals.", error); } } diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index cb0116c8550..88691cfc25e 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals { VersionMismatchDismissalsSchema, ) ?? { keys: [] } ); - } catch { + } catch (error) { + console.error("Could not read version-mismatch dismissals.", error); return { keys: [] }; } } @@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo document, VersionMismatchDismissalsSchema, ); - } catch { - // Dismissal state is best-effort UI state; a storage failure should not block the banner. + } catch (error) { + console.error("Could not persist version-mismatch dismissals.", error); } } From ee10b993ad6ab2a25fbf766566e61c132b24267b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:00:54 -0700 Subject: [PATCH 2/3] Report panel width persistence failures Co-authored-by: codex --- apps/web/src/hooks/useResizableWidth.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index d3c7207c185..08c067471f7 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { try { const stored = getLocalStorageItem(storageKey, WidthSchema); return clamp(stored ?? defaultWidth); - } catch { + } catch (error) { + console.error("Could not read persisted panel width.", error); return defaultWidth; } }); @@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { // Commit once at drag-end to avoid 60Hz localStorage writes. try { setLocalStorageItem(storageKey, finalWidth, WidthSchema); - } catch { - // localStorage may be full / disabled; the in-memory state still wins. + } catch (error) { + console.error("Could not persist panel width.", error); } setWidth(finalWidth); }, From 287f9dedc29243fe63942cfff39e319976e18bec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:18:14 -0700 Subject: [PATCH 3/3] Report file explorer persistence failures Co-authored-by: codex --- apps/web/src/components/files/FilePreviewPanel.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ba0be2da2da..89176cd4525 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -12,12 +12,14 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useTheme } from "~/hooks/useTheme"; +import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -589,8 +591,9 @@ function RenderedMarkdownSurface({ function initialExplorerOpen(): boolean { try { - return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; - } catch { + return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; + } catch (error) { + console.error(error); return true; } } @@ -650,8 +653,10 @@ export default function FilePreviewPanel({ setExplorerOpen((current) => { const next = !current; try { - window.localStorage.setItem(FILE_EXPLORER_STORAGE_KEY, String(next)); - } catch {} + setLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, next, Schema.Boolean); + } catch (error) { + console.error(error); + } return next; }); };