Unified · Split
| @@ -51,4 +51,22 @@ describe("clientPersistenceStorage", () => { · 50 unmodified lines | ||
| 51 | 51 | |
| 52 | 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 | }); |
| @@ -15,7 +15,8 @@ export function readBrowserClientSettings(): ClientSettings | null { · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | try { |
| 17 | 17 | return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); |
| 18 | − } catch { | |
| 18 | + } catch (error) { | |
| 19 | + console.error("Could not read persisted client settings.", error); | |
| 19 | 20 | return null; |
| 20 | 21 | } |
| 21 | 22 | } |
| @@ -12,12 +12,14 @@ import { · 11 unmodified lines | ||
| 12 | 12 | squashAtomCommandFailure, |
| 13 | 13 | } from "@t3tools/client-runtime/state/runtime"; |
| 14 | 14 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; |
| 15 | +import * as Schema from "effect/Schema"; | |
| 15 | 16 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 16 | 17 | |
| 17 | 18 | import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; |
| 18 | 19 | import ChatMarkdown from "~/components/ChatMarkdown"; |
| 19 | 20 | import { OpenInPicker } from "~/components/chat/OpenInPicker"; |
| 20 | 21 | import { useTheme } from "~/hooks/useTheme"; |
| 22 | +import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; | |
| 21 | 23 | import { resolveDiffThemeName } from "~/lib/diffRendering"; |
| 22 | 24 | import { cn } from "~/lib/utils"; |
| 23 | 25 | import { isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| @@ -589,8 +591,9 @@ function RenderedMarkdownSurface({ · 565 unmodified lines | ||
| 589 | 591 | |
| 590 | 592 | function initialExplorerOpen(): boolean { |
| 591 | 593 | 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); | |
| 594 | 597 | return true; |
| 595 | 598 | } |
| 596 | 599 | } |
| @@ -650,8 +653,10 @@ export default function FilePreviewPanel({ · 53 unmodified lines | ||
| 650 | 653 | setExplorerOpen((current) => { |
| 651 | 654 | const next = !current; |
| 652 | 655 | 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 | + } | |
| 655 | 660 | return next; |
| 656 | 661 | }); |
| 657 | 662 | }; |
| @@ -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 | +}); | |
| @@ -2,6 +2,19 @@ import * as Schema from "effect/Schema"; · 1 unmodified line | ||
| 2 | 2 | import * as Record from "effect/Record"; |
| 3 | 3 | import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 4 | 4 | |
| 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 | + | |
| 5 | 18 | const isomorphicLocalStorage: Storage = |
| 6 | 19 | typeof window !== "undefined" |
| 7 | 20 | ? window.localStorage |
| @@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage = · 11 unmodified lines | ||
| 19 | 32 | }; |
| 20 | 33 | })(); |
| 21 | 34 | |
| 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 | + } | |
| 25 | 49 | }; |
| 26 | 50 | |
| 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 | + } | |
| 30 | 57 | }; |
| 31 | 58 | |
| 32 | 59 | 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; | |
| 35 | 62 | }; |
| 36 | 63 | |
| 37 | 64 | 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 | + } | |
| 40 | 71 | }; |
| 41 | 72 | |
| 42 | 73 | 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 | + } | |
| 44 | 79 | }; |
| 45 | 80 | |
| 46 | 81 | const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; |
| @@ -51,11 +86,15 @@ interface LocalStorageChangeDetail { · 4 unmodified lines | ||
| 51 | 86 | |
| 52 | 87 | function dispatchLocalStorageChange(key: string) { |
| 53 | 88 | 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 | + } | |
| 59 | 98 | } |
| 60 | 99 | |
| 61 | 100 | export function useLocalStorage<T, E>( |
| @@ -65,9 +104,9 @@ export function useLocalStorage<T, E>( · 3 unmodified lines | ||
| 65 | 104 | ): [T, (value: T | ((val: T) => T)) => void] { |
| 66 | 105 | const getSnapshot = useCallback(() => { |
| 67 | 106 | try { |
| 68 | − return isomorphicLocalStorage.getItem(key); | |
| 107 | + return read(key); | |
| 69 | 108 | } catch (error) { |
| 70 | − console.error("[LOCALSTORAGE] Error:", error); | |
| 109 | + console.error("[LOCALSTORAGE] Could not read stored value.", error); | |
| 71 | 110 | return null; |
| 72 | 111 | } |
| 73 | 112 | }, [key]); |
| @@ -101,19 +140,31 @@ export function useLocalStorage<T, E>( · 27 unmodified lines | ||
| 101 | 140 | return initialValue; |
| 102 | 141 | } |
| 103 | 142 | try { |
| 104 | − return decode(schema, serializedValue); | |
| 143 | + return decode(key, schema, serializedValue); | |
| 105 | 144 | } catch (error) { |
| 106 | − console.error("[LOCALSTORAGE] Error:", error); | |
| 145 | + console.error("[LOCALSTORAGE] Could not decode stored value.", error); | |
| 107 | 146 | return initialValue; |
| 108 | 147 | } |
| 109 | − }, [initialValue, schema, serializedValue]); | |
| 148 | + }, [initialValue, key, schema, serializedValue]); | |
| 110 | 149 | |
| 111 | 150 | const setValue = useCallback( |
| 112 | 151 | (value: T | ((val: T) => T)) => { |
| 113 | 152 | try { |
| 114 | 153 | 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 | + } | |
| 117 | 168 | if (valueToStore === null) { |
| 118 | 169 | removeLocalStorageItem(key); |
| 119 | 170 | } else { |
| @@ -121,7 +172,7 @@ export function useLocalStorage<T, E>( · 1 unmodified line | ||
| 121 | 172 | } |
| 122 | 173 | dispatchLocalStorageChange(key); |
| 123 | 174 | } catch (error) { |
| 124 | − console.error("[LOCALSTORAGE] Error:", error); | |
| 175 | + console.error("[LOCALSTORAGE] Could not update stored value.", error); | |
| 125 | 176 | } |
| 126 | 177 | }, |
| 127 | 178 | [initialValue, key, schema], |
| @@ -55,7 +55,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { · 54 unmodified lines | ||
| 55 | 55 | try { |
| 56 | 56 | const stored = getLocalStorageItem(storageKey, WidthSchema); |
| 57 | 57 | return clamp(stored ?? defaultWidth); |
| 58 | − } catch { | |
| 58 | + } catch (error) { | |
| 59 | + console.error("Could not read persisted panel width.", error); | |
| 59 | 60 | return defaultWidth; |
| 60 | 61 | } |
| 61 | 62 | }); |
| @@ -141,8 +142,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { · 79 unmodified lines | ||
| 141 | 142 | // Commit once at drag-end to avoid 60Hz localStorage writes. |
| 142 | 143 | try { |
| 143 | 144 | 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); | |
| 146 | 147 | } |
| 147 | 148 | setWidth(finalWidth); |
| 148 | 149 | }, |
| @@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals { · 20 unmodified lines | ||
| 21 | 21 | keys: [], |
| 22 | 22 | } |
| 23 | 23 | ); |
| 24 | − } catch { | |
| 24 | + } catch (error) { | |
| 25 | + console.error("Could not read provider-update dismissals.", error); | |
| 25 | 26 | return { keys: [] }; |
| 26 | 27 | } |
| 27 | 28 | } |
| @@ -33,8 +34,8 @@ function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void · 5 unmodified lines | ||
| 33 | 34 | document, |
| 34 | 35 | ProviderUpdateDismissalsSchema, |
| 35 | 36 | ); |
| 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); | |
| 38 | 39 | } |
| 39 | 40 | } |
| 40 | 41 | |
| @@ -64,7 +64,8 @@ function readVersionMismatchDismissals(): VersionMismatchDismissals { · 63 unmodified lines | ||
| 64 | 64 | VersionMismatchDismissalsSchema, |
| 65 | 65 | ) ?? { keys: [] } |
| 66 | 66 | ); |
| 67 | − } catch { | |
| 67 | + } catch (error) { | |
| 68 | + console.error("Could not read version-mismatch dismissals.", error); | |
| 68 | 69 | return { keys: [] }; |
| 69 | 70 | } |
| 70 | 71 | } |
| @@ -76,8 +77,8 @@ function writeVersionMismatchDismissals(document: VersionMismatchDismissals): vo · 5 unmodified lines | ||
| 76 | 77 | document, |
| 77 | 78 | VersionMismatchDismissalsSchema, |
| 78 | 79 | ); |
| 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); | |
| 81 | 82 | } |
| 82 | 83 | } |
| 83 | 84 | |