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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user