From a396aa8a9dc4648e19cd3b445e8fe49fca393101 Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Fri, 15 Aug 2025 10:44:14 +1000 Subject: [PATCH] perf: settingstate caching improvements --- src/seqta/utils/listeners/SettingsState.ts | 142 ++++++++++++++++---- src/seqta/utils/listeners/StorageChanges.ts | 77 ++++++----- 2 files changed, 157 insertions(+), 62 deletions(-) diff --git a/src/seqta/utils/listeners/SettingsState.ts b/src/seqta/utils/listeners/SettingsState.ts index 52c07e50..22633442 100644 --- a/src/seqta/utils/listeners/SettingsState.ts +++ b/src/seqta/utils/listeners/SettingsState.ts @@ -8,15 +8,18 @@ type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void; class StorageManager { private static instance: StorageManager; private data: SettingsState; - private listeners: { [key: string]: ChangeListener[] }; - private globalListeners: GlobalChangeListener[]; + private listeners: Map>; + private globalListeners: Set; private subscribers: Set> = new Set(); + private saveTimeout: NodeJS.Timeout | null = null; + private pendingSave = false; + private initialized = false; private constructor() { this.data = {} as SettingsState; - this.listeners = {}; - this.globalListeners = []; - this.loadFromStorage(); + this.listeners = new Map(); + this.globalListeners = new Set(); + // Don't call async loadFromStorage in constructor const handler: ProxyHandler = { get: (target, prop: keyof SettingsState | "register" | "initialize") => { @@ -26,8 +29,21 @@ class StorageManager { return Reflect.get(target.data, prop); }, set: (target, prop: keyof SettingsState, value) => { - Reflect.set(target.data, prop, value); - target.saveToStorage(); + const oldValue = target.data[prop]; + + // Only save if the value actually changed + if (oldValue !== value) { + Reflect.set(target.data, prop, value); + target.saveToStorage(); + + // Notify listeners immediately for responsiveness + const listeners = target.listeners.get(prop as string); + if (listeners) { + for (const listener of listeners) { + listener(value, oldValue); + } + } + } return true; }, deleteProperty: (target, prop: keyof SettingsState) => { @@ -35,8 +51,9 @@ class StorageManager { if (oldValue !== undefined) { delete target.data[prop]; target.removeFromStorage(prop); - if (target.listeners[prop]) { - for (const listener of target.listeners[prop]) { + const listeners = target.listeners.get(prop as string); + if (listeners) { + for (const listener of listeners) { listener(undefined, oldValue); } } @@ -59,7 +76,10 @@ class StorageManager { public static async initialize(): Promise { const instance = StorageManager.getInstance(); - await instance.loadFromStorage(); + if (!instance.initialized) { + await instance.loadFromStorage(); + instance.initialized = true; + } return instance; } @@ -67,8 +87,19 @@ class StorageManager { key: K, value: SettingsState[K], ): void { - this.data[key] = value; - this.saveToStorage(); + const oldValue = this.data[key]; + if (oldValue !== value) { + this.data[key] = value; + this.saveToStorage(); + + // Notify listeners + const listeners = this.listeners.get(key as string); + if (listeners) { + for (const listener of listeners) { + listener(value, oldValue); + } + } + } } private async loadFromStorage(): Promise { @@ -79,9 +110,34 @@ class StorageManager { } private async saveToStorage(): Promise { + // Clear any existing timeout + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + + // Set a new timeout to batch changes + this.saveTimeout = setTimeout(async () => { + if (this.pendingSave) { + // @ts-expect-error + await browser.storage.local.set(this.data); + this.notifySubscribers(); + this.pendingSave = false; + } + }, 100); // Adjust delay as needed + + this.pendingSave = true; + } + + // Add immediate save method for critical updates + public async saveImmediately(): Promise { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } // @ts-expect-error await browser.storage.local.set(this.data); this.notifySubscribers(); + this.pendingSave = false; } private async removeFromStorage(key: string): Promise { @@ -91,19 +147,35 @@ class StorageManager { private initStorageListener(): void { browser.storage.onChanged.addListener((changes, areaName) => { if (areaName === "local") { + const actualChanges: string[] = []; + for (const [key, { oldValue, newValue }] of Object.entries(changes)) { - if (newValue !== undefined) { - (this.data as any)[key] = newValue; - } else { - delete (this.data as any)[key]; - } - if (this.listeners[key]) { - for (const listener of this.listeners[key]) { - listener(newValue, oldValue); + // Only process if value actually changed + if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { + if (newValue !== undefined) { + (this.data as any)[key] = newValue; + } else { + delete (this.data as any)[key]; + } + actualChanges.push(key); + + // Notify specific listeners + const listeners = this.listeners.get(key); + if (listeners) { + for (const listener of listeners) { + listener(newValue, oldValue); + } } } + } + + // Only notify global listeners if there were actual changes + if (actualChanges.length > 0 && this.globalListeners.size > 0) { for (const listener of this.globalListeners) { - listener(newValue, oldValue, key); + for (const key of actualChanges) { + const { oldValue, newValue } = changes[key]; + listener(newValue, oldValue, key); + } } } } @@ -116,18 +188,36 @@ class StorageManager { * @param listener The listener to call when the setting changes -> takes two arguments, (newValue, oldValue) */ public register(prop: keyof SettingsState, listener: ChangeListener): void { - if (!this.listeners[prop]) { - this.listeners[prop] = []; + const key = prop as string; + if (!this.listeners.has(key)) { + this.listeners.set(key, new Set()); } - this.listeners[prop].push(listener); + this.listeners.get(key)!.add(listener); + } + + /** + * Unregister a listener for a setting. + * @param prop The setting to stop listening to. + * @param listener The listener to remove. + */ + public unregister(prop: keyof SettingsState, listener: ChangeListener): void { + this.listeners.get(prop as string)?.delete(listener); } /** * Register a listener for any setting. - * @param listener The listener to call when any setting changes -> takes two arguments, (newValue, oldValue) + * @param listener The listener to call when any setting changes -> takes three arguments, (newValue, oldValue, key) */ public registerGlobal(listener: GlobalChangeListener): void { - this.globalListeners.push(listener); + this.globalListeners.add(listener); + } + + /** + * Unregister a global listener. + * @param listener The listener to remove. + */ + public unregisterGlobal(listener: GlobalChangeListener): void { + this.globalListeners.delete(listener); } /** diff --git a/src/seqta/utils/listeners/StorageChanges.ts b/src/seqta/utils/listeners/StorageChanges.ts index cf363b42..3997420d 100644 --- a/src/seqta/utils/listeners/StorageChanges.ts +++ b/src/seqta/utils/listeners/StorageChanges.ts @@ -46,21 +46,20 @@ export class StorageChangeHandler { newValue: CustomShortcut[], oldValue: CustomShortcut[], ) { - if (newValue) { - if (newValue.length > oldValue.length) { - CreateCustomShortcutDiv(newValue[oldValue.length]); - } else if (newValue.length < oldValue.length) { - const removedElement = oldValue.find( - (oldItem: any) => - !newValue.some( - (newItem: any) => - JSON.stringify(oldItem) === JSON.stringify(newItem), - ), - ); + if (!newValue || !oldValue) return; + + if (newValue.length > oldValue.length) { + // New shortcut added - add the last one + CreateCustomShortcutDiv(newValue[oldValue.length]); + } else if (newValue.length < oldValue.length) { + // Shortcut removed - find which one was removed + const newSet = new Set(newValue.map(item => JSON.stringify(item))); + const removedElement = oldValue.find( + (oldItem) => !newSet.has(JSON.stringify(oldItem)) + ); - if (removedElement) { - RemoveShortcutDiv([removedElement]); - } + if (removedElement) { + RemoveShortcutDiv([removedElement]); } } } @@ -69,29 +68,35 @@ export class StorageChangeHandler { newValue: { enabled: boolean; name: string }[], oldValue: { enabled: boolean; name: string }[], ) { - const addedShortcuts = newValue.filter((newItem: any) => { - const wasDisabledAndNowEnabled = oldValue.some((oldItem: any) => { - return oldItem.name === newItem.name && !oldItem.enabled && newItem.enabled; - }); + if (!newValue || !oldValue) return; + + // Create map for faster lookup + const oldMap = new Map(oldValue.map(item => [item.name, item.enabled])); + + const addedShortcuts: { enabled: boolean; name: string }[] = []; + const removedShortcuts: { enabled: boolean; name: string }[] = []; + + // Check for changes in shortcuts + for (const newItem of newValue) { + const oldEnabled = oldMap.get(newItem.name); + + // Newly enabled shortcuts + if (newItem.enabled && (oldEnabled === undefined || !oldEnabled)) { + addedShortcuts.push(newItem); + } + + // Newly disabled shortcuts + if (!newItem.enabled && oldEnabled === true) { + removedShortcuts.push(newItem); + } + } - const isNewShortcut = !oldValue.some((oldItem: any) => oldItem.name === newItem.name); - - return (wasDisabledAndNowEnabled || isNewShortcut) && newItem.enabled; - }); - - const removedShortcuts = newValue.filter((newItem: any) => { - const isRemoved = oldValue.some((oldItem: any) => { - const match = oldItem.name === newItem.name; - const wasEnabled = oldItem.enabled; - const isDisabled = !newItem.enabled; - return match && wasEnabled && isDisabled; - }); - - return isRemoved; - }); - - addShortcuts(addedShortcuts); - RemoveShortcutDiv(removedShortcuts); + if (addedShortcuts.length > 0) { + addShortcuts(addedShortcuts); + } + if (removedShortcuts.length > 0) { + RemoveShortcutDiv(removedShortcuts); + } } private handleTransparencyEffectsChange(newValue: boolean) {