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) {