refactor(ui): parse the scoped theme entry at the boundary; runtime-switch owns transient keys

The scoped theme entry is now parsed by one boundary parser with a
stated invariant instead of ad hoc typeof narrowing, and the runtime
keys that mean "no instance connected" live next to the code that
produces them so a new sentinel cannot miss the theme-storage guard.
This commit is contained in:
Bohdan Triapitsyn
2026-08-30 13:29:52 +03:00
parent 73fd2e9d9d
commit 6eec839fbf
4 changed files with 63 additions and 55 deletions
+5 -5
View File
@@ -27,7 +27,7 @@ import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device'
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint, MOBILE_DISCONNECTED_RUNTIME_KEY } from '@/lib/runtime-switch';
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache';
import { cn } from '@/lib/utils';
@@ -686,7 +686,7 @@ export function MobileApp({ apis }: MobileAppProps) {
};
const disconnect = (reason: string) => {
logMobileConnectEvent('resume:disconnect', { reason });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
};
@@ -896,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) {
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
if (notice) setAutoConnectNotice(notice);
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
};
void reprobeActiveConnection().then(async (outcome) => {
@@ -1196,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) {
type="button"
variant="outline"
onClick={() => {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
}}
>
@@ -1279,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) {
<OpenCodeUpdateToast />
<MobileAppUpdateToast />
<MobileShell onActiveConnectionDeleted={() => {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1);
}} />
<AppLinkConfirmDialog />
@@ -5,12 +5,12 @@ import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/theme
import {
adoptThemePreferencesForRuntime,
getThemePreferencesStorageKey,
isTransientRuntimeKey,
readThemePreferencesForRuntime,
resolveThemePreferencesForRuntime,
resolveThemePreferencesFromStorageEvent,
writeThemePreferencesForRuntime,
} from './theme-storage';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
let createdWindow = false;
let createdLocalStorage = false;
+46 -49
View File
@@ -1,5 +1,6 @@
import type { ThemeMode } from '@/types/theme';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
type StoredThemePreferences = {
themeMode: ThemeMode;
@@ -21,56 +22,56 @@ const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:';
export const getThemePreferencesStorageKey = (runtimeKey: string): string =>
`${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
// Runtime keys that mean "no instance connected" — the uninitialized default
// and the mobile disconnect state. They carry no instance theme, so scoped
// storage must not read or write them: a write would pin whatever theme was
// current at that moment (e.g. cold-boot defaults) to a key every future
// launch resolves before connecting, and a read would surface that stale
// entry on the mobile connect splash. The global splash hints are the right
// fallback for those phases.
const TRANSIENT_RUNTIME_KEYS = new Set(['', 'url:default', 'mobile-disconnected']);
const THEME_MODES: readonly ThemeMode[] = ['light', 'dark', 'system'];
export const isTransientRuntimeKey = (runtimeKey: string): boolean =>
TRANSIENT_RUNTIME_KEYS.has(runtimeKey);
const isThemeMode = (value: string): value is ThemeMode =>
THEME_MODES.some((mode) => mode === value);
export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => {
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) {
return null;
}
let raw: string | null = null;
// Boundary parser for the scoped entry. A malformed or partial payload is a
// failure (`null`), never a valid default: the caller then falls back to the
// legacy seed or keeps its current preferences.
const parseStoredThemePreferences = (raw: string): StoredThemePreferences | null => {
try {
raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey));
} catch {
return null;
}
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
// SAFETY: this key is written only by `writeThemePreferencesForRuntime`
// with exactly this shape. Every field is still re-checked below, and a
// field of the wrong type throws on `.trim()` into the catch.
const candidate = JSON.parse(raw) as Partial<StoredThemePreferences> | null;
if (candidate === null) {
return null;
}
const candidate = parsed as Record<string, unknown>;
if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') {
const themeMode = candidate.themeMode ?? '';
if (!isThemeMode(themeMode)) {
return null;
}
if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') {
return null;
}
const lightThemeId = candidate.lightThemeId.trim();
const darkThemeId = candidate.darkThemeId.trim();
const lightThemeId = (candidate.lightThemeId ?? '').trim();
const darkThemeId = (candidate.darkThemeId ?? '').trim();
if (!lightThemeId || !darkThemeId) {
return null;
}
return { themeMode: candidate.themeMode, lightThemeId, darkThemeId };
return { themeMode, lightThemeId, darkThemeId };
} catch {
return null;
}
};
const readLocalStorageItem = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => {
if (isTransientRuntimeKey(runtimeKey)) {
return null;
}
const raw = readLocalStorageItem(getThemePreferencesStorageKey(runtimeKey));
return raw ? parseStoredThemePreferences(raw) : null;
};
export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => {
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) {
if (isTransientRuntimeKey(runtimeKey)) {
return;
}
try {
@@ -118,16 +119,12 @@ const readLegacyThemePreferences = (): StoredThemePreferences => {
let lightThemeId: string = DEFAULT_LIGHT_THEME_ID;
let darkThemeId: string = DEFAULT_DARK_THEME_ID;
if (typeof window === 'undefined') {
return { themeMode, lightThemeId, darkThemeId };
}
const legacyMode = readLocalStorageItem('themeMode');
const legacyUseSystem = readLocalStorageItem('useSystemTheme');
const legacyThemeId = readLocalStorageItem('selectedThemeId');
const legacyVariant = readLocalStorageItem('selectedThemeVariant');
const legacyMode = localStorage.getItem('themeMode');
const legacyUseSystem = localStorage.getItem('useSystemTheme');
const legacyThemeId = localStorage.getItem('selectedThemeId');
const legacyVariant = localStorage.getItem('selectedThemeVariant');
if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') {
if (legacyMode !== null && isThemeMode(legacyMode)) {
themeMode = legacyMode;
} else if (legacyUseSystem !== null) {
const useSystem = legacyUseSystem === 'true';
@@ -148,13 +145,13 @@ const readLegacyThemePreferences = (): StoredThemePreferences => {
themeMode = legacyVariant;
}
const legacyLightId = localStorage.getItem('lightThemeId');
const legacyDarkId = localStorage.getItem('darkThemeId');
if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) {
lightThemeId = legacyLightId.trim();
const legacyLightId = readLocalStorageItem('lightThemeId')?.trim();
const legacyDarkId = readLocalStorageItem('darkThemeId')?.trim();
if (legacyLightId) {
lightThemeId = legacyLightId;
}
if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) {
darkThemeId = legacyDarkId.trim();
if (legacyDarkId) {
darkThemeId = legacyDarkId;
}
return { themeMode, lightThemeId, darkThemeId };
+11
View File
@@ -51,6 +51,17 @@ const normalizeRuntimeUrlKey = (value: string): string => {
}
};
// Runtime keys that mean "no instance connected": the uninitialized default
// (`normalizeRuntimeUrlKey` of an empty/unparseable base URL) and the mobile
// disconnect state (`MobileApp` switches to it when the connection drops).
// Per-instance client state (e.g. the scoped theme entry) must not be read
// from or written under them.
export const MOBILE_DISCONNECTED_RUNTIME_KEY = 'mobile-disconnected';
const UNINITIALIZED_RUNTIME_KEY = 'url:default';
export const isTransientRuntimeKey = (runtimeKey: string): boolean =>
runtimeKey === '' || runtimeKey === UNINITIALIZED_RUNTIME_KEY || runtimeKey === MOBILE_DISCONNECTED_RUNTIME_KEY;
const readInjectedApiBaseUrl = (): string => {
if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;