Skip to content

Commit 8d776f7

Browse files
committed
Harden theme and terminal state storage for non-browser envs
- guard theme hook against missing window/document/localStorage and add server snapshot - add in-memory fallback storage for persisted terminal state when localStorage is unavailable - simplify brittle platform/global test setup to match new runtime-safe behavior
1 parent 7bfabe4 commit 8d776f7

4 files changed

Lines changed: 56 additions & 46 deletions

File tree

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,6 @@
11
import { MessageId } from "@t3tools/contracts";
22
import { renderToStaticMarkup } from "react-dom/server";
3-
import { beforeAll, describe, expect, it, vi } from "vitest";
4-
5-
function matchMedia() {
6-
return {
7-
matches: false,
8-
addEventListener: () => {},
9-
removeEventListener: () => {},
10-
};
11-
}
12-
13-
beforeAll(() => {
14-
const classList = {
15-
add: () => {},
16-
remove: () => {},
17-
toggle: () => {},
18-
contains: () => false,
19-
};
20-
21-
vi.stubGlobal("localStorage", {
22-
getItem: () => null,
23-
setItem: () => {},
24-
removeItem: () => {},
25-
clear: () => {},
26-
});
27-
vi.stubGlobal("window", {
28-
matchMedia,
29-
addEventListener: () => {},
30-
removeEventListener: () => {},
31-
desktopBridge: undefined,
32-
});
33-
vi.stubGlobal("document", {
34-
documentElement: {
35-
classList,
36-
offsetHeight: 0,
37-
},
38-
});
39-
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
40-
callback(0);
41-
return 0;
42-
});
43-
});
3+
import { describe, expect, it } from "vitest";
444

455
describe("MessagesTimeline", () => {
466
it("renders inline terminal labels with the composer chip UI", async () => {

apps/web/src/hooks/useTheme.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,36 @@ type ThemeSnapshot = {
88

99
const STORAGE_KEY = "t3code:theme";
1010
const MEDIA_QUERY = "(prefers-color-scheme: dark)";
11+
const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
12+
theme: "system",
13+
systemDark: false,
14+
};
1115

1216
let listeners: Array<() => void> = [];
1317
let lastSnapshot: ThemeSnapshot | null = null;
1418
let lastDesktopTheme: Theme | null = null;
19+
1520
function emitChange() {
1621
for (const listener of listeners) listener();
1722
}
1823

19-
function getSystemDark(): boolean {
20-
return window.matchMedia(MEDIA_QUERY).matches;
24+
function hasThemeStorage() {
25+
return typeof window !== "undefined" && typeof localStorage !== "undefined";
26+
}
27+
28+
function getSystemDark() {
29+
return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches;
2130
}
2231

2332
function getStored(): Theme {
33+
if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme;
2434
const raw = localStorage.getItem(STORAGE_KEY);
2535
if (raw === "light" || raw === "dark" || raw === "system") return raw;
26-
return "system";
36+
return DEFAULT_THEME_SNAPSHOT.theme;
2737
}
2838

2939
function applyTheme(theme: Theme, suppressTransitions = false) {
40+
if (typeof document === "undefined" || typeof window === "undefined") return;
3041
if (suppressTransitions) {
3142
document.documentElement.classList.add("no-transitions");
3243
}
@@ -44,6 +55,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
4455
}
4556

4657
function syncDesktopTheme(theme: Theme) {
58+
if (typeof window === "undefined") return;
4759
const bridge = window.desktopBridge;
4860
if (!bridge || lastDesktopTheme === theme) {
4961
return;
@@ -58,9 +70,12 @@ function syncDesktopTheme(theme: Theme) {
5870
}
5971

6072
// Apply immediately on module load to prevent flash
61-
applyTheme(getStored());
73+
if (typeof document !== "undefined" && hasThemeStorage()) {
74+
applyTheme(getStored());
75+
}
6276

6377
function getSnapshot(): ThemeSnapshot {
78+
if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT;
6479
const theme = getStored();
6580
const systemDark = theme === "system" ? getSystemDark() : false;
6681

@@ -72,7 +87,12 @@ function getSnapshot(): ThemeSnapshot {
7287
return lastSnapshot;
7388
}
7489

90+
function getServerSnapshot() {
91+
return DEFAULT_THEME_SNAPSHOT;
92+
}
93+
7594
function subscribe(listener: () => void): () => void {
95+
if (typeof window === "undefined") return () => {};
7696
listeners.push(listener);
7797

7898
// Listen for system preference changes
@@ -100,13 +120,14 @@ function subscribe(listener: () => void): () => void {
100120
}
101121

102122
export function useTheme() {
103-
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
123+
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
104124
const theme = snapshot.theme;
105125

106126
const resolvedTheme: "light" | "dark" =
107127
theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;
108128

109129
const setTheme = useCallback((next: Theme) => {
130+
if (!hasThemeStorage()) return;
110131
localStorage.setItem(STORAGE_KEY, next);
111132
applyTheme(next, true);
112133
emitChange();

apps/web/src/terminalStateStore.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./terminalStateStore";
99

1010
const THREAD_ID = ThreadId.makeUnsafe("thread-1");
11+
const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
1112

1213
function makeTerminalEvent(
1314
type: TerminalEvent["type"],

apps/web/src/terminalStateStore.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,34 @@ function createTerminalStateStorage() {
4545
return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
4646
}
4747

48+
function createMemoryStorage(): StateStorage {
49+
const store = new Map<string, string>();
50+
return {
51+
getItem: (name) => store.get(name) ?? null,
52+
setItem: (name, value) => {
53+
store.set(name, value);
54+
},
55+
removeItem: (name) => {
56+
store.delete(name);
57+
},
58+
};
59+
}
60+
61+
function createTerminalStateStorage() {
62+
if (
63+
typeof localStorage !== "undefined" &&
64+
typeof localStorage.getItem === "function" &&
65+
typeof localStorage.setItem === "function" &&
66+
typeof localStorage.removeItem === "function"
67+
) {
68+
return localStorage;
69+
}
70+
71+
return createMemoryStorage();
72+
}
73+
74+
const terminalStateStorage = createTerminalStateStorage();
75+
4876
function normalizeTerminalIds(terminalIds: string[]): string[] {
4977
const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
5078
return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];

0 commit comments

Comments
 (0)