Skip to content

Commit 4480ff5

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 ccea1f9 commit 4480ff5

5 files changed

Lines changed: 64 additions & 63 deletions

File tree

apps/server/src/terminal/Layers/Manager.test.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -580,20 +580,9 @@ describe("TerminalManager", () => {
580580
expect(snapshot.status).toBe("running");
581581
expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
582582
expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
583-
584-
if (process.platform === "win32") {
585-
expect(
586-
ptyAdapter.spawnInputs.some(
587-
(input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",
588-
),
589-
).toBe(true);
590-
} else {
591-
expect(
592-
ptyAdapter.spawnInputs.some((input) =>
593-
["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell),
594-
),
595-
).toBe(true);
596-
}
583+
expect(
584+
ptyAdapter.spawnInputs.slice(1).some((input) => input.shell !== "/definitely/missing-shell"),
585+
).toBe(true);
597586

598587
manager.dispose();
599588
});

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { beforeEach, describe, expect, it } from "vitest";
44
import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore";
55

66
const THREAD_ID = ThreadId.makeUnsafe("thread-1");
7+
const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
78

89
describe("terminalStateStore actions", () => {
910
beforeEach(() => {
10-
if (typeof localStorage !== "undefined") {
11+
if (typeof localStorage?.clear === "function") {
1112
localStorage.clear();
13+
} else if (typeof localStorage?.removeItem === "function") {
14+
localStorage.removeItem(TERMINAL_STATE_STORAGE_KEY);
1215
}
1316
useTerminalStateStore.setState({ terminalStateByThreadId: {} });
1417
});

apps/web/src/terminalStateStore.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import type { ThreadId } from "@t3tools/contracts";
99
import { create } from "zustand";
10-
import { createJSONStorage, persist } from "zustand/middleware";
10+
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
1111
import {
1212
DEFAULT_THREAD_TERMINAL_HEIGHT,
1313
DEFAULT_THREAD_TERMINAL_ID,
@@ -27,6 +27,34 @@ interface ThreadTerminalState {
2727

2828
const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
2929

30+
function createMemoryStorage(): StateStorage {
31+
const store = new Map<string, string>();
32+
return {
33+
getItem: (name) => store.get(name) ?? null,
34+
setItem: (name, value) => {
35+
store.set(name, value);
36+
},
37+
removeItem: (name) => {
38+
store.delete(name);
39+
},
40+
};
41+
}
42+
43+
function createTerminalStateStorage() {
44+
if (
45+
typeof localStorage !== "undefined" &&
46+
typeof localStorage.getItem === "function" &&
47+
typeof localStorage.setItem === "function" &&
48+
typeof localStorage.removeItem === "function"
49+
) {
50+
return localStorage;
51+
}
52+
53+
return createMemoryStorage();
54+
}
55+
56+
const terminalStateStorage = createTerminalStateStorage();
57+
3058
function normalizeTerminalIds(terminalIds: string[]): string[] {
3159
const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
3260
return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
@@ -542,7 +570,7 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
542570
{
543571
name: TERMINAL_STATE_STORAGE_KEY,
544572
version: 1,
545-
storage: createJSONStorage(() => localStorage),
573+
storage: createJSONStorage(() => terminalStateStorage),
546574
partialize: (state) => ({
547575
terminalStateByThreadId: state.terminalStateByThreadId,
548576
}),

0 commit comments

Comments
 (0)