fix(settings): flush pending debounced settings writes on page unload (#2197)

This commit is contained in:
Serhii Dziupin
2026-08-06 19:48:51 +00:00
parent fe331c093b
commit 90512d0e06
3 changed files with 116 additions and 1 deletions
+84
View File
@@ -568,3 +568,87 @@ describe('updateDesktopSettings', () => {
expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true);
});
});
describe('unload lifecycle flush (#2197)', () => {
beforeEach(() => {
getWindow();
registerRuntimeAPIs(null);
invalidateSettingsCache();
});
test('flushes a pending debounced settings save on pagehide without a double write', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
const update = updateDesktopSettings({ showDeletionDialog: false });
expect(saveCalls).toEqual([]);
getWindow().dispatchEvent(new Event('pagehide'));
// The flush must hand the pending changes to the settings backend
// synchronously inside the lifecycle listener — an unloading window has
// no later turn for the debounce timer.
expect(saveCalls).toEqual([{ showDeletionDialog: false }]);
await update;
await delay(300);
// The canceled debounce timer must not replay the same write.
expect(saveCalls).toHaveLength(1);
});
test('flushes a pending debounced settings save on beforeunload without a double write', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
const update = updateDesktopSettings({ gitChangesViewMode: 'tree' });
expect(saveCalls).toEqual([]);
getWindow().dispatchEvent(new Event('beforeunload'));
expect(saveCalls).toEqual([{ gitChangesViewMode: 'tree' }]);
await update;
await delay(300);
expect(saveCalls).toHaveLength(1);
});
test('persists a showDeletionDialog toggle followed by an immediate unload', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
startAppearanceAutoSave();
try {
useUIStore.getState().setShowDeletionDialog(false);
getWindow().dispatchEvent(new Event('pagehide'));
expect(saveCalls.some((changes) => changes.showDeletionDialog === false)).toBe(true);
} finally {
useUIStore.getState().setShowDeletionDialog(true);
// Let the restore write drain so it cannot leak into other tests.
await delay(300);
}
});
test('ignores lifecycle events when no settings write is pending', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
saveCalls.push(changes);
return {};
});
getWindow().dispatchEvent(new Event('pagehide'));
getWindow().dispatchEvent(new Event('beforeunload'));
await delay(50);
expect(saveCalls).toEqual([]);
});
});
+31
View File
@@ -1625,6 +1625,21 @@ const isSettingsRuntimeContextCurrent = (context: SettingsRuntimeContext): boole
context.generation === _settingsRuntimeGeneration && context.runtimeKey === getRuntimeKey()
);
// Best-effort flush of the pending debounced settings write at a lifecycle
// boundary. Clearing the timer before flushing means the write happens exactly
// once — the flush consumes the pending changes, so a timer that already fired
// cannot double-write. A hard process kill (crash, task-manager kill) can
// still lose the in-flight request; this narrows the loss window to the
// request itself instead of the whole debounce interval (#2197).
const flushPendingSettingsBeforeSuspend = (): void => {
if (!_pendingSettingsChanges) return;
if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer);
_settingsFlushTimer = null;
}
void _flushSettingsUpdate();
};
const ensureSettingsRuntimeLifecycle = (): void => {
if (_settingsLifecycleInitialized || typeof window === 'undefined') return;
_settingsLifecycleInitialized = true;
@@ -1640,6 +1655,22 @@ const ensureSettingsRuntimeLifecycle = (): void => {
_settingsCache = null;
_settingsInflight = null;
});
// Mirror the deferred safe-storage lifecycle: without these listeners, a
// settings change made within SETTINGS_DEBOUNCE_MS of closing the window is
// silently dropped, and the stale server snapshot wins on next startup.
try {
window.addEventListener('pagehide', flushPendingSettingsBeforeSuspend, { capture: true });
window.addEventListener('beforeunload', flushPendingSettingsBeforeSuspend, { capture: true });
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushPendingSettingsBeforeSuspend();
});
document.addEventListener('freeze', flushPendingSettingsBeforeSuspend);
}
} catch {
// Restricted environments can reject listeners; the debounce timer still flushes.
}
};
const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Promise<DesktopSettings | null> => {
+1 -1
View File
@@ -71,7 +71,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors.
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request.
Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode.