fix(ui): preserve upstream behavior after performance rebase
This commit is contained in:
@@ -548,6 +548,99 @@ describe('updateDesktopSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('does not broadcast a stale project selection over a newer pending update', async () => {
|
||||
const firstSave = deferred<SettingsPayload>();
|
||||
const savedChanges: Array<Partial<SettingsPayload>> = [];
|
||||
registerSettingsSave(async (changes) => {
|
||||
savedChanges.push(changes);
|
||||
if (savedChanges.length === 1) return firstSave.promise;
|
||||
return changes as SettingsPayload;
|
||||
});
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const firstUpdate = updateDesktopSettings({ activeProjectId: 'project-a' });
|
||||
await delay(250);
|
||||
const secondUpdate = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||
|
||||
firstSave.resolve({ activeProjectId: 'project-a' });
|
||||
await firstUpdate;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||
|
||||
await secondUpdate;
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not broadcast a stale loaded project selection over a newer pending update', async () => {
|
||||
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const sync = syncDesktopSettings();
|
||||
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||
|
||||
loadedSettings.resolve({
|
||||
settings: {
|
||||
activeProjectId: 'project-a',
|
||||
draftStartersCraftGoalAdded: true,
|
||||
draftStartersScheduleTaskAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
});
|
||||
await sync;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||
|
||||
await update;
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not broadcast a stale load after a newer project update has saved', async () => {
|
||||
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const sync = syncDesktopSettings();
|
||||
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||
await update;
|
||||
|
||||
loadedSettings.resolve({
|
||||
settings: {
|
||||
activeProjectId: 'project-a',
|
||||
draftStartersCraftGoalAdded: true,
|
||||
draftStartersScheduleTaskAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
});
|
||||
await sync;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies model selector settings from server settings', async () => {
|
||||
getWindow();
|
||||
const settings = {
|
||||
|
||||
@@ -1669,6 +1669,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
};
|
||||
|
||||
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
|
||||
type SettingsMutation = { revision: number; changes: Partial<DesktopSettings> };
|
||||
type SettingsOperation = SettingsRuntimeContext & { id: number; revision: number };
|
||||
|
||||
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
|
||||
let _settingsRuntimeGeneration = 0;
|
||||
@@ -1679,9 +1681,53 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null;
|
||||
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _settingsFlushWaiters: Array<() => void> = [];
|
||||
let _settingsLifecycleInitialized = false;
|
||||
let _settingsMutationRevision = 0;
|
||||
let _settingsOperationId = 0;
|
||||
let _pendingSettingsRevision = 0;
|
||||
let _settingsMutations: SettingsMutation[] = [];
|
||||
const _settingsOperations = new Map<number, SettingsOperation>();
|
||||
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
|
||||
const SETTINGS_DEBOUNCE_MS = 200;
|
||||
|
||||
const recordSettingsMutation = (changes: Partial<DesktopSettings>): number => {
|
||||
_settingsMutationRevision += 1;
|
||||
_settingsMutations.push({ revision: _settingsMutationRevision, changes });
|
||||
return _settingsMutationRevision;
|
||||
};
|
||||
|
||||
const beginSettingsOperation = (
|
||||
revision = _settingsMutationRevision,
|
||||
context = captureSettingsRuntimeContext(),
|
||||
): SettingsOperation => {
|
||||
_settingsOperationId += 1;
|
||||
const operation = { id: _settingsOperationId, revision, ...context };
|
||||
_settingsOperations.set(operation.id, operation);
|
||||
return operation;
|
||||
};
|
||||
|
||||
const reconcileSettingsOperation = (
|
||||
settings: DesktopSettings,
|
||||
operation: SettingsOperation,
|
||||
): DesktopSettings => {
|
||||
let reconciled = settings;
|
||||
for (const mutation of _settingsMutations) {
|
||||
if (mutation.revision <= operation.revision) continue;
|
||||
reconciled = { ...reconciled, ...mutation.changes };
|
||||
}
|
||||
return reconciled;
|
||||
};
|
||||
|
||||
const finishSettingsOperation = (operation: SettingsOperation): void => {
|
||||
if (!isSettingsRuntimeContextCurrent(operation)) return;
|
||||
_settingsOperations.delete(operation.id);
|
||||
if (_settingsOperations.size === 0) {
|
||||
_settingsMutations = [];
|
||||
return;
|
||||
}
|
||||
const oldestOperationRevision = Math.min(...[..._settingsOperations.values()].map(({ revision }) => revision));
|
||||
_settingsMutations = _settingsMutations.filter((mutation) => mutation.revision > oldestOperationRevision);
|
||||
};
|
||||
|
||||
const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
|
||||
runtimeKey: getRuntimeKey(),
|
||||
generation: _settingsRuntimeGeneration,
|
||||
@@ -1707,6 +1753,9 @@ const ensureSettingsRuntimeLifecycle = (): void => {
|
||||
subscribeRuntimeEndpointChanged((detail) => {
|
||||
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
||||
_settingsRuntimeGeneration += 1;
|
||||
_settingsMutations = [];
|
||||
_settingsOperations.clear();
|
||||
_pendingSettingsRevision = 0;
|
||||
_settingsCache = null;
|
||||
_settingsInflight = null;
|
||||
});
|
||||
@@ -1780,6 +1829,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
ensureSettingsRuntimeLifecycle();
|
||||
const context = captureSettingsRuntimeContext();
|
||||
const operation = beginSettingsOperation(_settingsMutationRevision, context);
|
||||
|
||||
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
|
||||
|
||||
@@ -1816,8 +1866,12 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
|
||||
// a TypeError from writing to a contextBridge-protected global) doesn't
|
||||
// prevent server settings from reaching the Zustand store.
|
||||
const applySettings = async (settings: DesktopSettings) => {
|
||||
const applySettings = async (loadedSettings: DesktopSettings) => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
let settings = reconcileSettingsOperation(loadedSettings, operation);
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
settings = reconcileSettingsOperation(loadedSettings, operation);
|
||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
||||
|| settings.draftStartersScheduleTaskAdded !== true;
|
||||
// `autoSaveEnabled` is new to the settings backend. Until the server has a
|
||||
@@ -1836,8 +1890,6 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
} catch (error) {
|
||||
console.warn('persistToLocalStorage failed:', error);
|
||||
}
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (shouldSeedAutoSaveEnabled) {
|
||||
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
||||
}
|
||||
@@ -1900,6 +1952,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to synchronise settings:', error);
|
||||
} finally {
|
||||
finishSettingsOperation(operation);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1907,9 +1961,11 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
async function _flushSettingsUpdate(): Promise<void> {
|
||||
const changes = _pendingSettingsChanges;
|
||||
const context = _pendingSettingsContext;
|
||||
const revision = _pendingSettingsRevision;
|
||||
const waiters = _settingsFlushWaiters;
|
||||
_pendingSettingsChanges = null;
|
||||
_pendingSettingsContext = null;
|
||||
_pendingSettingsRevision = 0;
|
||||
_settingsFlushTimer = null;
|
||||
_settingsFlushWaiters = [];
|
||||
try {
|
||||
@@ -1918,59 +1974,66 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
dispatchSettingsSaveState('saved');
|
||||
return;
|
||||
}
|
||||
const operation = beginSettingsOperation(revision, context);
|
||||
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
const reconciled = reconcileSettingsOperation(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
_settingsCache = null;
|
||||
}
|
||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
console.warn('Failed to update settings via runtime settings API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
try {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
||||
dispatchSettingsSaveState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = sanitizeWebSettings(await response.json().catch(() => null));
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
const reconciled = reconcileSettingsOperation(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
} else {
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
console.warn('Failed to update settings via runtime settings API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
try {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
||||
dispatchSettingsSaveState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = sanitizeWebSettings(await response.json().catch(() => null));
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
} else {
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSettingsRuntimeContextCurrent(context)) {
|
||||
console.warn('Failed to update shared settings via API:', error);
|
||||
dispatchSettingsSaveState('error');
|
||||
if (isSettingsRuntimeContextCurrent(context)) {
|
||||
console.warn('Failed to update shared settings via API:', error);
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishSettingsOperation(operation);
|
||||
}
|
||||
} finally {
|
||||
waiters.forEach((resolve) => resolve());
|
||||
@@ -1991,6 +2054,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
|
||||
|
||||
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
|
||||
_pendingSettingsContext = context;
|
||||
_pendingSettingsRevision = recordSettingsMutation(changes);
|
||||
dispatchSettingsSaveState('saving');
|
||||
|
||||
if (_settingsFlushTimer) {
|
||||
|
||||
Reference in New Issue
Block a user