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 { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch'; 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 { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache'; import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -686,7 +686,7 @@ export function MobileApp({ apis }: MobileAppProps) {
}; };
const disconnect = (reason: string) => { const disconnect = (reason: string) => {
logMobileConnectEvent('resume:disconnect', { reason }); logMobileConnectEvent('resume:disconnect', { reason });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}; };
@@ -896,7 +896,7 @@ export function MobileApp({ apis }: MobileAppProps) {
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => { const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' }); logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
if (notice) setAutoConnectNotice(notice); if (notice) setAutoConnectNotice(notice);
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}; };
void reprobeActiveConnection().then(async (outcome) => { void reprobeActiveConnection().then(async (outcome) => {
@@ -1196,7 +1196,7 @@ export function MobileApp({ apis }: MobileAppProps) {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}} }}
> >
@@ -1279,7 +1279,7 @@ export function MobileApp({ apis }: MobileAppProps) {
<OpenCodeUpdateToast /> <OpenCodeUpdateToast />
<MobileAppUpdateToast /> <MobileAppUpdateToast />
<MobileShell onActiveConnectionDeleted={() => { <MobileShell onActiveConnectionDeleted={() => {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: MOBILE_DISCONNECTED_RUNTIME_KEY });
setConnectionEpoch((value) => value + 1); setConnectionEpoch((value) => value + 1);
}} /> }} />
<AppLinkConfirmDialog /> <AppLinkConfirmDialog />
@@ -5,12 +5,12 @@ import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/theme
import { import {
adoptThemePreferencesForRuntime, adoptThemePreferencesForRuntime,
getThemePreferencesStorageKey, getThemePreferencesStorageKey,
isTransientRuntimeKey,
readThemePreferencesForRuntime, readThemePreferencesForRuntime,
resolveThemePreferencesForRuntime, resolveThemePreferencesForRuntime,
resolveThemePreferencesFromStorageEvent, resolveThemePreferencesFromStorageEvent,
writeThemePreferencesForRuntime, writeThemePreferencesForRuntime,
} from './theme-storage'; } from './theme-storage';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
let createdWindow = false; let createdWindow = false;
let createdLocalStorage = false; let createdLocalStorage = false;
+46 -49
View File
@@ -1,5 +1,6 @@
import type { ThemeMode } from '@/types/theme'; import type { ThemeMode } from '@/types/theme';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
type StoredThemePreferences = { type StoredThemePreferences = {
themeMode: ThemeMode; themeMode: ThemeMode;
@@ -21,56 +22,56 @@ const THEME_PREFERENCES_KEY_PREFIX = 'openchamber.theme.v2:';
export const getThemePreferencesStorageKey = (runtimeKey: string): string => export const getThemePreferencesStorageKey = (runtimeKey: string): string =>
`${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`; `${THEME_PREFERENCES_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
// Runtime keys that mean "no instance connected" — the uninitialized default const THEME_MODES: readonly ThemeMode[] = ['light', 'dark', 'system'];
// 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']);
export const isTransientRuntimeKey = (runtimeKey: string): boolean => const isThemeMode = (value: string): value is ThemeMode =>
TRANSIENT_RUNTIME_KEYS.has(runtimeKey); THEME_MODES.some((mode) => mode === value);
export const readThemePreferencesForRuntime = (runtimeKey: string): StoredThemePreferences | null => { // Boundary parser for the scoped entry. A malformed or partial payload is a
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { // failure (`null`), never a valid default: the caller then falls back to the
return null; // legacy seed or keeps its current preferences.
} const parseStoredThemePreferences = (raw: string): StoredThemePreferences | null => {
let raw: string | null = null;
try { try {
raw = localStorage.getItem(getThemePreferencesStorageKey(runtimeKey)); // SAFETY: this key is written only by `writeThemePreferencesForRuntime`
} catch { // with exactly this shape. Every field is still re-checked below, and a
return null; // field of the wrong type throws on `.trim()` into the catch.
} const candidate = JSON.parse(raw) as Partial<StoredThemePreferences> | null;
if (!raw) { if (candidate === null) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return null; return null;
} }
const candidate = parsed as Record<string, unknown>; const themeMode = candidate.themeMode ?? '';
if (candidate.themeMode !== 'light' && candidate.themeMode !== 'dark' && candidate.themeMode !== 'system') { if (!isThemeMode(themeMode)) {
return null; return null;
} }
if (typeof candidate.lightThemeId !== 'string' || typeof candidate.darkThemeId !== 'string') { const lightThemeId = (candidate.lightThemeId ?? '').trim();
return null; const darkThemeId = (candidate.darkThemeId ?? '').trim();
}
const lightThemeId = candidate.lightThemeId.trim();
const darkThemeId = candidate.darkThemeId.trim();
if (!lightThemeId || !darkThemeId) { if (!lightThemeId || !darkThemeId) {
return null; return null;
} }
return { themeMode: candidate.themeMode, lightThemeId, darkThemeId }; return { themeMode, lightThemeId, darkThemeId };
} catch { } catch {
return null; 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 => { export const writeThemePreferencesForRuntime = (runtimeKey: string, preferences: StoredThemePreferences): void => {
if (typeof window === 'undefined' || isTransientRuntimeKey(runtimeKey)) { if (isTransientRuntimeKey(runtimeKey)) {
return; return;
} }
try { try {
@@ -118,16 +119,12 @@ const readLegacyThemePreferences = (): StoredThemePreferences => {
let lightThemeId: string = DEFAULT_LIGHT_THEME_ID; let lightThemeId: string = DEFAULT_LIGHT_THEME_ID;
let darkThemeId: string = DEFAULT_DARK_THEME_ID; let darkThemeId: string = DEFAULT_DARK_THEME_ID;
if (typeof window === 'undefined') { const legacyMode = readLocalStorageItem('themeMode');
return { themeMode, lightThemeId, darkThemeId }; const legacyUseSystem = readLocalStorageItem('useSystemTheme');
} const legacyThemeId = readLocalStorageItem('selectedThemeId');
const legacyVariant = readLocalStorageItem('selectedThemeVariant');
const legacyMode = localStorage.getItem('themeMode'); if (legacyMode !== null && isThemeMode(legacyMode)) {
const legacyUseSystem = localStorage.getItem('useSystemTheme');
const legacyThemeId = localStorage.getItem('selectedThemeId');
const legacyVariant = localStorage.getItem('selectedThemeVariant');
if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') {
themeMode = legacyMode; themeMode = legacyMode;
} else if (legacyUseSystem !== null) { } else if (legacyUseSystem !== null) {
const useSystem = legacyUseSystem === 'true'; const useSystem = legacyUseSystem === 'true';
@@ -148,13 +145,13 @@ const readLegacyThemePreferences = (): StoredThemePreferences => {
themeMode = legacyVariant; themeMode = legacyVariant;
} }
const legacyLightId = localStorage.getItem('lightThemeId'); const legacyLightId = readLocalStorageItem('lightThemeId')?.trim();
const legacyDarkId = localStorage.getItem('darkThemeId'); const legacyDarkId = readLocalStorageItem('darkThemeId')?.trim();
if (typeof legacyLightId === 'string' && legacyLightId.trim().length > 0) { if (legacyLightId) {
lightThemeId = legacyLightId.trim(); lightThemeId = legacyLightId;
} }
if (typeof legacyDarkId === 'string' && legacyDarkId.trim().length > 0) { if (legacyDarkId) {
darkThemeId = legacyDarkId.trim(); darkThemeId = legacyDarkId;
} }
return { themeMode, lightThemeId, darkThemeId }; 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 => { const readInjectedApiBaseUrl = (): string => {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__; const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;