From 4454bf7e1458219ccaa9bcc3b8613c317f8f35cd Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Tue, 25 Aug 2026 01:01:32 +0800 Subject: [PATCH] refactor(ui): encapsulate settings mutation tracking Keep settings mutation revisions and in-flight operations behind a private tracker so stale GET and PUT responses cannot overwrite newer local settings. Remove redundant runtime identity and numeric operation IDs. Use operation object identity while preserving runtime reset and mutation retention behavior. Tests: bun test src/lib/persistence.test.ts --- packages/ui/src/lib/persistence.ts | 109 ++++++++++++++--------------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index c0a37eba..ea9fef52 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1670,7 +1670,49 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { type SettingsRuntimeContext = { runtimeKey: string; generation: number }; type SettingsMutation = { revision: number; changes: Partial }; -type SettingsOperation = SettingsRuntimeContext & { id: number; revision: number }; +type SettingsOperation = { revision: number }; + +class SettingsMutationTracker { + private revision = 0; + private mutations: SettingsMutation[] = []; + private operations = new Set(); + + record(changes: Partial): number { + this.revision += 1; + this.mutations.push({ revision: this.revision, changes }); + return this.revision; + } + + begin(revision = this.revision): SettingsOperation { + const operation = { revision }; + this.operations.add(operation); + return operation; + } + + reconcile(settings: DesktopSettings, operation: SettingsOperation): DesktopSettings { + let reconciled = settings; + for (const mutation of this.mutations) { + if (mutation.revision <= operation.revision) continue; + reconciled = { ...reconciled, ...mutation.changes }; + } + return reconciled; + } + + finish(operation: SettingsOperation): void { + if (!this.operations.delete(operation)) return; + if (this.operations.size === 0) { + this.mutations = []; + return; + } + const oldestRevision = Math.min(...[...this.operations].map(({ revision }) => revision)); + this.mutations = this.mutations.filter((mutation) => mutation.revision > oldestRevision); + } + + reset(): void { + this.mutations = []; + this.operations.clear(); + } +} // Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup let _settingsRuntimeGeneration = 0; @@ -1681,53 +1723,11 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null; let _settingsFlushTimer: ReturnType | 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(); +const _settingsMutationTracker = new SettingsMutationTracker(); const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst const SETTINGS_DEBOUNCE_MS = 200; -const recordSettingsMutation = (changes: Partial): 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, @@ -1753,8 +1753,7 @@ const ensureSettingsRuntimeLifecycle = (): void => { subscribeRuntimeEndpointChanged((detail) => { if (detail.runtimeKey === detail.previousRuntimeKey) return; _settingsRuntimeGeneration += 1; - _settingsMutations = []; - _settingsOperations.clear(); + _settingsMutationTracker.reset(); _pendingSettingsRevision = 0; _settingsCache = null; _settingsInflight = null; @@ -1829,7 +1828,7 @@ export const syncDesktopSettings = async (): Promise => { } ensureSettingsRuntimeLifecycle(); const context = captureSettingsRuntimeContext(); - const operation = beginSettingsOperation(_settingsMutationRevision, context); + const operation = _settingsMutationTracker.begin(); const persistApis = [getPersistApi(), useSessionDisplayStore.persist]; @@ -1868,10 +1867,10 @@ export const syncDesktopSettings = async (): Promise => { // prevent server settings from reaching the Zustand store. const applySettings = async (loadedSettings: DesktopSettings) => { if (!isSettingsRuntimeContextCurrent(context)) return; - let settings = reconcileSettingsOperation(loadedSettings, operation); + let settings = _settingsMutationTracker.reconcile(loadedSettings, operation); await waitForHydration(); if (!isSettingsRuntimeContextCurrent(context)) return; - settings = reconcileSettingsOperation(loadedSettings, operation); + settings = _settingsMutationTracker.reconcile(loadedSettings, operation); const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true || settings.draftStartersScheduleTaskAdded !== true; // `autoSaveEnabled` is new to the settings backend. Until the server has a @@ -1953,7 +1952,7 @@ export const syncDesktopSettings = async (): Promise => { } catch (error) { console.warn('Failed to synchronise settings:', error); } finally { - finishSettingsOperation(operation); + _settingsMutationTracker.finish(operation); } }; @@ -1974,7 +1973,7 @@ async function _flushSettingsUpdate(): Promise { dispatchSettingsSaveState('saved'); return; } - const operation = beginSettingsOperation(revision, context); + const operation = _settingsMutationTracker.begin(revision); try { const runtimeSettings = getRuntimeSettingsAPI(); @@ -1983,7 +1982,7 @@ async function _flushSettingsUpdate(): Promise { const updated = await runtimeSettings.save(changes); if (!isSettingsRuntimeContextCurrent(context)) return; if (updated) { - const reconciled = reconcileSettingsOperation(updated, operation); + const reconciled = _settingsMutationTracker.reconcile(updated, operation); applyDesktopUiPreferences(reconciled); dispatchSettingsSynced(reconciled); _settingsCache = null; @@ -2017,7 +2016,7 @@ async function _flushSettingsUpdate(): Promise { const updated = sanitizeWebSettings(await response.json().catch(() => null)); if (!isSettingsRuntimeContextCurrent(context)) return; if (updated) { - const reconciled = reconcileSettingsOperation(updated, operation); + const reconciled = _settingsMutationTracker.reconcile(updated, operation); applyDesktopUiPreferences(reconciled); dispatchSettingsSynced(reconciled); dispatchSettingsSaveState('saved'); @@ -2033,7 +2032,7 @@ async function _flushSettingsUpdate(): Promise { } } } finally { - finishSettingsOperation(operation); + _settingsMutationTracker.finish(operation); } } finally { waiters.forEach((resolve) => resolve()); @@ -2054,7 +2053,7 @@ export const updateDesktopSettings = async (changes: Partial): _pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes }; _pendingSettingsContext = context; - _pendingSettingsRevision = recordSettingsMutation(changes); + _pendingSettingsRevision = _settingsMutationTracker.record(changes); dispatchSettingsSaveState('saving'); if (_settingsFlushTimer) {