fix(ui): stop settings save echoes from clobbering theme preferences

Every updateDesktopSettings PUT replays the server's full settings
document as an openchamber:settings-synced event, and the theme listener
adopted it unconditionally - so switching sessions across directories
(any lastDirectory/activeProjectId write) could flip the theme to
whatever the server document held at that moment. A bootstrap GET with
missing theme fields made it worse: materializeAuthoritativeUiSettings
invented useSystemTheme + openchamber defaults and the persist effect
wrote them back to the server and the scoped localStorage entry.

Theme is now adopted only from bootstrap-grade syncs (the renamed
bootstrap flag, formerly adoptWorkspace), missing fields mean 'not set'
and keep the current preference, and the materializer no longer invents
theme defaults. Cross-window same-instance theme sync still rides the
scoped-key storage event; runtime endpoint switches still adopt the new
instance's server theme.

Closes the 'theme flashes to OpenChamber when switching sessions' report;
same family as the pre-#2897 'color mode forced to light' symptom.
This commit is contained in:
Pablo
2026-08-31 21:18:43 +02:00
parent 715c33b83c
commit a42fb91daf
7 changed files with 174 additions and 53 deletions
@@ -34,6 +34,7 @@ import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-sw
import {
adoptThemePreferencesForRuntime,
resolveThemePreferencesForRuntime,
resolveThemePreferencesFromSettingsSync,
resolveThemePreferencesFromStorageEvent,
writeThemePreferencesForRuntime,
} from './theme-storage';
@@ -590,46 +591,14 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return;
}
const handleSettingsSynced = (event: Event) => {
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings;
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings
? (event as CustomEvent<SettingsSyncedDetail>).detail
: null;
if (!detail) {
return;
}
setPreferences((prev) => {
let nextMode = prev.themeMode;
if (detail.useSystemTheme === true) {
nextMode = 'system';
} else if (detail.useSystemTheme === false) {
if (detail.themeVariant === 'dark' || detail.themeVariant === 'light') {
nextMode = detail.themeVariant;
}
}
let nextLight = prev.lightThemeId;
if (typeof detail.lightThemeId === 'string' && detail.lightThemeId.length > 0) {
nextLight = detail.lightThemeId.trim();
}
let nextDark = prev.darkThemeId;
if (typeof detail.darkThemeId === 'string' && detail.darkThemeId.length > 0) {
nextDark = detail.darkThemeId.trim();
}
const same =
nextMode === prev.themeMode &&
nextLight === prev.lightThemeId &&
nextDark === prev.darkThemeId;
if (same) {
return prev;
}
return {
themeMode: nextMode,
lightThemeId: nextLight,
darkThemeId: nextDark,
};
});
setPreferences((prev) => resolveThemePreferencesFromSettingsSync(detail, prev) ?? prev);
};
window.addEventListener('openchamber:settings-synced', handleSettingsSynced);
@@ -7,6 +7,7 @@ import {
getThemePreferencesStorageKey,
readThemePreferencesForRuntime,
resolveThemePreferencesForRuntime,
resolveThemePreferencesFromSettingsSync,
resolveThemePreferencesFromStorageEvent,
writeThemePreferencesForRuntime,
} from './theme-storage';
@@ -288,3 +289,40 @@ describe('theme storage event resolution', () => {
expect(resolveThemePreferencesFromStorageEvent(getThemePreferencesStorageKey('runtime-a'), 'runtime-a', current)).toBeNull();
});
});
describe('settings sync resolution', () => {
const current = { themeMode: 'system' as const, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' };
const serverTheme = { useSystemTheme: false as const, themeVariant: 'dark' as const, lightThemeId: 'server-light', darkThemeId: 'server-dark' };
test('a non-bootstrap sync (settings save echo) never changes preferences', () => {
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: false, settings: serverTheme }, current)).toBeNull();
expect(resolveThemePreferencesFromSettingsSync(null, current)).toBeNull();
});
test('a bootstrap sync adopts the server theme', () => {
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: serverTheme }, current)).toEqual({
themeMode: 'dark',
lightThemeId: 'server-light',
darkThemeId: 'server-dark',
});
});
test('theme fields omitted by the server keep the current preferences (not-set is not reset-to-defaults)', () => {
expect(resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: {} }, current)).toBeNull();
expect(
resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { useSystemTheme: true } }, current),
).toBeNull();
expect(
resolveThemePreferencesFromSettingsSync({ bootstrap: true, settings: { lightThemeId: 'server-light' } }, current),
).toEqual({ themeMode: 'system', lightThemeId: 'server-light', darkThemeId: 'dark-theme' });
});
test('a bootstrap sync carrying the current preferences resolves to no change', () => {
expect(
resolveThemePreferencesFromSettingsSync(
{ bootstrap: true, settings: { useSystemTheme: true, lightThemeId: 'light-theme', darkThemeId: 'dark-theme' } },
current,
),
).toBeNull();
});
});
+46
View File
@@ -1,4 +1,5 @@
import type { ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, getThemeById } from '@/lib/theme/themes';
import { isTransientRuntimeKey } from '@/lib/runtime-switch';
@@ -179,3 +180,48 @@ export const adoptThemePreferencesForRuntime = (
runtimeKey: string,
current: StoredThemePreferences,
): StoredThemePreferences => readThemePreferencesForRuntime(runtimeKey) ?? current;
type SettingsSyncThemePayload = Pick<
DesktopSettings,
'useSystemTheme' | 'themeVariant' | 'lightThemeId' | 'darkThemeId'
>;
/**
* Resolve the preferences a settings sync should apply. Returns null — meaning
* "keep current preferences" — when the sync is not bootstrap-grade (a
* mid-session save echo replays the server document, which may hold another
* window's theme, a default, or a stale value; the scoped entry and bootstrap
* syncs own this window's theme) and when the resolved values already match
* the current ones (the identity check breaks adoption loops).
*
* Theme fields absent from the server document mean "not set", not "reset to
* defaults": each one independently keeps the current preference. The payload
* is the already-sanitized settings document (sanitizeWebSettings), so fields
* present in it are domain-valid; an unknown id injected some other way fails
* theme lookup downstream and falls back cosmetically.
*/
export const resolveThemePreferencesFromSettingsSync = (
detail: { bootstrap: boolean; settings: SettingsSyncThemePayload } | null,
current: StoredThemePreferences,
): StoredThemePreferences | null => {
if (!detail?.bootstrap) {
return null;
}
const settings = detail.settings;
let themeMode = current.themeMode;
if (settings.useSystemTheme === true) {
themeMode = 'system';
} else if (settings.useSystemTheme === false && (settings.themeVariant === 'dark' || settings.themeVariant === 'light')) {
themeMode = settings.themeVariant;
}
const lightThemeId = settings.lightThemeId?.trim() || current.lightThemeId;
const darkThemeId = settings.darkThemeId?.trim() || current.darkThemeId;
if (themeMode === current.themeMode && lightThemeId === current.lightThemeId && darkThemeId === current.darkThemeId) {
return null;
}
return { themeMode, lightThemeId, darkThemeId };
};