Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/clientPersistenceStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
);
});
});
3 changes: 2 additions & 1 deletion apps/web/src/clientPersistenceStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
});
};
Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/hooks/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Storage {
const store = new Map<string, string>();
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,
});
}
});
});
99 changes: 75 additions & 24 deletions apps/web/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()(
"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
Expand All @@ -19,28 +32,50 @@ const isomorphicLocalStorage: Storage =
};
})();

const decode = <T, E>(schema: Schema.Codec<T, E>, 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 = <T, E>(key: string, schema: Schema.Codec<T, E>, value: string) => {
try {
return Schema.decodeSync(Schema.fromJsonString(schema))(value);
} catch (cause) {
throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause });
}
};

const encode = <T, E>(schema: Schema.Codec<T, E>, value: T) => {
const encodeJson = Schema.encodeSync(Schema.fromJsonString(schema));
return encodeJson(value);
const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
try {
return Schema.encodeSync(Schema.fromJsonString(schema))(value);
} catch (cause) {
throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause });
}
};

export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): 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 = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
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";
Expand All @@ -51,11 +86,15 @@ interface LocalStorageChangeDetail {

function dispatchLocalStorageChange(key: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
detail: { key },
}),
);
try {
window.dispatchEvent(
new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
detail: { key },
}),
);
} catch (cause) {
throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause });
}
}

export function useLocalStorage<T, E>(
Expand All @@ -65,9 +104,9 @@ export function useLocalStorage<T, E>(
): [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]);
Expand Down Expand Up @@ -101,27 +140,39 @@ export function useLocalStorage<T, E>(
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 {
setLocalStorageItem(key, valueToStore, schema);
}
dispatchLocalStorageChange(key);
} catch (error) {
console.error("[LOCALSTORAGE] Error:", error);
console.error("[LOCALSTORAGE] Could not update stored value.", error);
}
},
[initialValue, key, schema],
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/hooks/useResizableWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Expand Down Expand Up @@ -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);
},
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/providerUpdateDismissal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function readProviderUpdateDismissals(): ProviderUpdateDismissals {
keys: [],
}
);
} catch {
} catch (error) {
console.error("Could not read provider-update dismissals.", error);
return { keys: [] };
}
}
Expand All @@ -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);
}
}

Expand Down
Loading
Loading