diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 6050f5a498..9b376d23b9 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -278,6 +278,14 @@ export enum IpcChannel { Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user', Memory_GetUsersList = 'memory:get-users-list', + // Preference + Preference_Get = 'preference:get', + Preference_Set = 'preference:set', + Preference_GetMultiple = 'preference:get-multiple', + Preference_SetMultiple = 'preference:set-multiple', + Preference_Subscribe = 'preference:subscribe', + Preference_Changed = 'preference:changed', + // TRACE TRACE_SAVE_DATA = 'trace:saveData', TRACE_GET_DATA = 'trace:getData', diff --git a/packages/shared/data/preferences.ts b/packages/shared/data/preferences.ts index 1b6de8402f..49e2cf4fc8 100644 --- a/packages/shared/data/preferences.ts +++ b/packages/shared/data/preferences.ts @@ -332,7 +332,7 @@ export interface PreferencesType { } /* eslint sort-keys: ["error", "asc", {"caseSensitive": true, "natural": false}] */ -export const defaultPreferences: PreferencesType = { +export const DefaultPreferences: PreferencesType = { default: { 'app.developer_mode.enabled': false, 'app.disable_hardware_acceleration': false, diff --git a/src/main/data/PreferenceService.ts b/src/main/data/PreferenceService.ts new file mode 100644 index 0000000000..2aec6cb94c --- /dev/null +++ b/src/main/data/PreferenceService.ts @@ -0,0 +1,311 @@ +import { loggerService } from '@logger' +import type { PreferencesType } from '@shared/data/preferences' +import { DefaultPreferences } from '@shared/data/preferences' +import { IpcChannel } from '@shared/IpcChannel' +import { and, eq } from 'drizzle-orm' +import { BrowserWindow } from 'electron' + +import dbService from './db/DbService' +import { preferenceTable } from './db/schemas/preference' + +const logger = loggerService.withContext('PreferenceService') + +type PreferenceKey = keyof PreferencesType['default'] + +/** + * PreferenceService manages preference data storage and synchronization across multiple windows + * + * Features: + * - Memory-cached preferences for high performance + * - SQLite database persistence using Drizzle ORM + * - Multi-window subscription and synchronization + * - Type-safe preference operations + * - Batch operations support + * - Change notification broadcasting + */ +export class PreferenceService { + private static instance: PreferenceService + private subscriptions = new Map>() // windowId -> Set + private cache: Record = { ...DefaultPreferences.default } + private initialized = false + + private constructor() { + this.setupWindowCleanup() + } + + /** + * Get the singleton instance of PreferenceService + */ + public static getInstance(): PreferenceService { + if (!PreferenceService.instance) { + PreferenceService.instance = new PreferenceService() + } + return PreferenceService.instance + } + + /** + * Initialize preference cache from database + * Should be called once at application startup + */ + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + const db = dbService.getDb() + const results = await db.select().from(preferenceTable).where(eq(preferenceTable.scope, 'default')) + + // Update cache with database values, keeping defaults for missing keys + for (const result of results) { + const key = result.key as PreferenceKey + if (key in this.cache) { + this.cache[key] = result.value as any + } + } + + this.initialized = true + logger.info(`Preference cache initialized with ${results.length} values`) + } catch (error) { + logger.error('Failed to initialize preference cache:', error as Error) + // Keep default values on initialization failure + this.initialized = true + } + } + + /** + * Get a single preference value from memory cache + * Fast synchronous access - no database queries after initialization + */ + get(key: K): PreferencesType['default'][K] { + if (!this.initialized) { + logger.warn(`Preference cache not initialized, returning default for ${key}`) + return DefaultPreferences.default[key] + } + + return this.cache[key] ?? DefaultPreferences.default[key] + } + + /** + * Set a single preference value + * Updates both database and memory cache, then broadcasts changes to subscribed windows + */ + async set(key: K, value: PreferencesType['default'][K]): Promise { + try { + const db = dbService.getDb() + const scope = 'default' + + // First try to update existing record + const existing = await db + .select() + .from(preferenceTable) + .where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key))) + .limit(1) + + if (existing.length > 0) { + // Update existing record + await db + .update(preferenceTable) + .set({ + value: value as any + }) + .where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key))) + } else { + // Insert new record + await db.insert(preferenceTable).values({ + scope, + key, + value: value as any + }) + } + + // Update memory cache immediately + this.cache[key] = value + + // Broadcast change to subscribed windows + await this.notifyChange(key, value) + + logger.debug(`Preference ${key} updated successfully`) + } catch (error) { + logger.error(`Failed to set preference ${key}:`, error as Error) + throw error + } + } + + /** + * Get multiple preferences at once from memory cache + * Fast synchronous access - no database queries + */ + getMultiple(keys: string[]): Record { + if (!this.initialized) { + logger.warn('Preference cache not initialized, returning defaults for multiple keys') + const output: Record = {} + for (const key of keys) { + if (key in DefaultPreferences.default) { + output[key] = DefaultPreferences.default[key as PreferenceKey] + } else { + output[key] = undefined + } + } + return output + } + + const output: Record = {} + for (const key of keys) { + if (key in this.cache) { + output[key] = this.cache[key as PreferenceKey] + } else { + output[key] = undefined + } + } + + return output + } + + /** + * Set multiple preferences at once + * Updates both database and memory cache in a transaction, then broadcasts changes + */ + async setMultiple(updates: Record): Promise { + try { + const scope = 'default' + + await dbService.transaction(async (tx) => { + for (const [key, value] of Object.entries(updates)) { + // Check if record exists + const existing = await tx + .select() + .from(preferenceTable) + .where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key))) + .limit(1) + + if (existing.length > 0) { + // Update existing record + await tx + .update(preferenceTable) + .set({ + value + }) + .where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key))) + } else { + // Insert new record + await tx.insert(preferenceTable).values({ + scope, + key, + value + }) + } + } + }) + + // Update memory cache for all changed keys + for (const [key, value] of Object.entries(updates)) { + if (key in this.cache) { + this.cache[key as PreferenceKey] = value + } + } + + // Broadcast all changes + const changePromises = Object.entries(updates).map(([key, value]) => this.notifyChange(key, value)) + await Promise.all(changePromises) + + logger.debug(`Updated ${Object.keys(updates).length} preferences successfully`) + } catch (error) { + logger.error('Failed to set multiple preferences:', error as Error) + throw error + } + } + + /** + * Subscribe a window to preference changes + * Window will receive notifications for specified keys + */ + subscribe(windowId: number, keys: string[]): void { + if (!this.subscriptions.has(windowId)) { + this.subscriptions.set(windowId, new Set()) + } + + const windowKeys = this.subscriptions.get(windowId)! + keys.forEach((key) => windowKeys.add(key)) + + logger.debug(`Window ${windowId} subscribed to ${keys.length} preference keys`) + } + + /** + * Unsubscribe a window from preference changes + */ + unsubscribe(windowId: number): void { + this.subscriptions.delete(windowId) + logger.debug(`Window ${windowId} unsubscribed from preference changes`) + } + + /** + * Broadcast preference change to all subscribed windows + */ + private async notifyChange(key: string, value: any): Promise { + const affectedWindows: number[] = [] + + for (const [windowId, subscribedKeys] of this.subscriptions.entries()) { + if (subscribedKeys.has(key)) { + affectedWindows.push(windowId) + } + } + + if (affectedWindows.length === 0) { + return + } + + // Send to all affected windows + for (const windowId of affectedWindows) { + try { + const window = BrowserWindow.fromId(windowId) + if (window && !window.isDestroyed()) { + window.webContents.send(IpcChannel.Preference_Changed, key, value, 'default') + } else { + // Clean up invalid window subscription + this.subscriptions.delete(windowId) + } + } catch (error) { + logger.error(`Failed to notify window ${windowId}:`, error as Error) + this.subscriptions.delete(windowId) + } + } + + logger.debug(`Broadcasted preference change ${key} to ${affectedWindows.length} windows`) + } + + /** + * Setup automatic cleanup of closed window subscriptions + */ + private setupWindowCleanup(): void { + // This will be called when windows are closed + const cleanup = () => { + const validWindowIds = BrowserWindow.getAllWindows() + .filter((w) => !w.isDestroyed()) + .map((w) => w.id) + + const subscribedWindowIds = Array.from(this.subscriptions.keys()) + const invalidWindowIds = subscribedWindowIds.filter((id) => !validWindowIds.includes(id)) + + invalidWindowIds.forEach((id) => this.subscriptions.delete(id)) + + if (invalidWindowIds.length > 0) { + logger.debug(`Cleaned up ${invalidWindowIds.length} invalid window subscriptions`) + } + } + + // Run cleanup periodically (every 30 seconds) + setInterval(cleanup, 30000) + } + + /** + * Get all current subscriptions (for debugging) + */ + getSubscriptions(): Map> { + return new Map(this.subscriptions) + } +} + +// Export singleton instance +export const preferenceService = PreferenceService.getInstance() +export default preferenceService diff --git a/src/main/data/db/seeding/preferenceSeeding.ts b/src/main/data/db/seeding/preferenceSeeding.ts index 25ff0744be..9310704142 100644 --- a/src/main/data/db/seeding/preferenceSeeding.ts +++ b/src/main/data/db/seeding/preferenceSeeding.ts @@ -1,5 +1,5 @@ import { preferenceTable } from '@data/db/schemas/preference' -import { defaultPreferences } from '@shared/data/preferences' +import { DefaultPreferences } from '@shared/data/preferences' import type { DbType, ISeed } from '../types' @@ -18,7 +18,7 @@ class PreferenceSeed implements ISeed { }> = [] // Process each scope in defaultPreferences - for (const [scope, scopeData] of Object.entries(defaultPreferences)) { + for (const [scope, scopeData] of Object.entries(DefaultPreferences)) { // Process each key-value pair in the scope for (const [key, value] of Object.entries(scopeData)) { const prefKey = `${scope}.${key}` diff --git a/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts b/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts index 6fbefc22eb..f18a6f4b30 100644 --- a/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts +++ b/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts @@ -1,7 +1,7 @@ import dbService from '@data/db/DbService' import { preferenceTable } from '@data/db/schemas/preference' import { loggerService } from '@logger' -import { defaultPreferences } from '@shared/data/preferences' +import { DefaultPreferences } from '@shared/data/preferences' import { and, eq } from 'drizzle-orm' import { configManager } from '../../../../services/ConfigManager' @@ -151,7 +151,7 @@ export class PreferencesMigrator { // Process ElectronStore mappings - no sourceCategory needed ELECTRON_STORE_MAPPINGS.forEach((mapping) => { - const defaultValue = defaultPreferences.default[mapping.targetKey] ?? null + const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, targetKey: mapping.targetKey, @@ -164,7 +164,7 @@ export class PreferencesMigrator { // Process Redux mappings Object.entries(REDUX_STORE_MAPPINGS).forEach(([category, mappings]) => { mappings.forEach((mapping) => { - const defaultValue = defaultPreferences.default[mapping.targetKey] ?? null + const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, // May contain nested paths like "codeEditor.enabled" targetKey: mapping.targetKey, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e337d0d247..30641f653e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -13,6 +13,7 @@ import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' import { Notification } from 'src/renderer/src/types/notification' +import preferenceService from './data/PreferenceService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' @@ -696,4 +697,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { (_, spanId: string, modelName: string, context: string, msg: any) => addStreamMessage(spanId, modelName, context, msg) ) + + // Preference handlers + ipcMain.handle(IpcChannel.Preference_Get, async (_, key: string) => { + return preferenceService.get(key as any) + }) + + ipcMain.handle(IpcChannel.Preference_Set, async (_, key: string, value: any) => { + await preferenceService.set(key as any, value) + }) + + ipcMain.handle(IpcChannel.Preference_GetMultiple, async (_, keys: string[]) => { + return preferenceService.getMultiple(keys) + }) + + ipcMain.handle(IpcChannel.Preference_SetMultiple, async (_, updates: Record) => { + await preferenceService.setMultiple(updates) + }) + + ipcMain.handle(IpcChannel.Preference_Subscribe, async (event, keys: string[]) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id + if (windowId) { + preferenceService.subscribe(windowId, keys) + } + }) } diff --git a/src/main/services/PrefService.ts b/src/main/services/PrefService.ts deleted file mode 100644 index 0f6caff1e5..0000000000 --- a/src/main/services/PrefService.ts +++ /dev/null @@ -1 +0,0 @@ -class PrefService {} diff --git a/src/preload/index.ts b/src/preload/index.ts index a548ae8b21..6ff97125d1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { SpanContext } from '@opentelemetry/api' import { UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' +import type { PreferencesType } from '@shared/data/preferences' import { IpcChannel } from '@shared/IpcChannel' import { AddMemoryOptions, @@ -393,6 +394,24 @@ const api = { cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA), addStreamMessage: (spanId: string, modelName: string, context: string, message: any) => ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message) + }, + preference: { + get: (key: K) => ipcRenderer.invoke(IpcChannel.Preference_Get, key), + + set: (key: K, value: PreferencesType['default'][K]) => + ipcRenderer.invoke(IpcChannel.Preference_Set, key, value), + + getMultiple: (keys: string[]) => ipcRenderer.invoke(IpcChannel.Preference_GetMultiple, keys), + + setMultiple: (updates: Record) => ipcRenderer.invoke(IpcChannel.Preference_SetMultiple, updates), + + subscribe: (keys: string[]) => ipcRenderer.invoke(IpcChannel.Preference_Subscribe, keys), + + onChanged: (callback: (key: string, value: any, scope: string) => void) => { + const listener = (_: any, key: string, value: any, scope: string) => callback(key, value, scope) + ipcRenderer.on(IpcChannel.Preference_Changed, listener) + return () => ipcRenderer.off(IpcChannel.Preference_Changed, listener) + } } } diff --git a/src/renderer/src/data/PreferenceService.ts b/src/renderer/src/data/PreferenceService.ts new file mode 100644 index 0000000000..76a9349c14 --- /dev/null +++ b/src/renderer/src/data/PreferenceService.ts @@ -0,0 +1,322 @@ +import { loggerService } from '@logger' +import type { PreferencesType } from '@shared/data/preferences' +import { DefaultPreferences } from '@shared/data/preferences' + +const logger = loggerService.withContext('PreferenceService') + +type PreferenceKey = keyof PreferencesType['default'] + +/** + * Renderer-side PreferenceService providing cached access to preferences + * with real-time synchronization across windows using useSyncExternalStore + */ +export class PreferenceService { + private static instance: PreferenceService + private cache = new Map() + private listeners = new Set<() => void>() + private keyListeners = new Map void>>() + private changeListenerCleanup: (() => void) | null = null + private subscribedKeys = new Set() + + private constructor() { + this.setupChangeListener() + // Initialize window source for logging if not already done + if (typeof loggerService.initWindowSource === 'function') { + try { + loggerService.initWindowSource('main') + } catch (error) { + // Window source already initialized, ignore error + } + } + } + + /** + * Get the singleton instance of PreferenceService + */ + static getInstance(): PreferenceService { + if (!PreferenceService.instance) { + PreferenceService.instance = new PreferenceService() + } + return PreferenceService.instance + } + + /** + * Setup IPC change listener for preference updates from main process + */ + private setupChangeListener() { + if (!window.api?.preference?.onChanged) { + logger.error('Preference API not available in preload context') + return + } + + this.changeListenerCleanup = window.api.preference.onChanged((key, value, scope) => { + // Only handle default scope since we simplified API + if (scope !== 'default') { + return + } + + const oldValue = this.cache.get(key) + + if (oldValue !== value) { + this.cache.set(key, value) + this.notifyListeners(key) + logger.debug(`Preference ${key} updated to:`, { value }) + } + }) + } + + /** + * Notify all relevant listeners about preference changes + */ + private notifyListeners(key: string) { + // Notify global listeners + this.listeners.forEach((listener) => listener()) + + // Notify specific key listeners + const keyListeners = this.keyListeners.get(key) + if (keyListeners) { + keyListeners.forEach((listener) => listener()) + } + } + + /** + * Get a single preference value with caching + */ + async get(key: K): Promise { + // Check cache first + if (this.cache.has(key)) { + return this.cache.get(key) + } + + try { + // Fetch from main process if not cached + const value = await window.api.preference.get(key) + this.cache.set(key, value) + + // Auto-subscribe to this key for future updates + if (!this.subscribedKeys.has(key)) { + await this.subscribeToKeyInternal(key) + } + + return value + } catch (error) { + logger.error(`Failed to get preference ${key}:`, error as Error) + // Return default value on error + return DefaultPreferences.default[key] as PreferencesType['default'][K] + } + } + + /** + * Set a single preference value + */ + async set(key: K, value: PreferencesType['default'][K]): Promise { + try { + await window.api.preference.set(key, value) + + // Update local cache immediately for responsive UI + this.cache.set(key, value) + this.notifyListeners(key) + + logger.debug(`Preference ${key} set to:`, { value }) + } catch (error) { + logger.error(`Failed to set preference ${key}:`, error as Error) + throw error + } + } + + /** + * Get multiple preferences at once + */ + async getMultiple(keys: string[]): Promise> { + // Check which keys are already cached + const cachedResults: Record = {} + const uncachedKeys: string[] = [] + + for (const key of keys) { + if (this.cache.has(key)) { + cachedResults[key] = this.cache.get(key) + } else { + uncachedKeys.push(key) + } + } + + // Fetch uncached keys from main process + if (uncachedKeys.length > 0) { + try { + const uncachedResults = await window.api.preference.getMultiple(uncachedKeys) + + // Update cache with new results + for (const [key, value] of Object.entries(uncachedResults)) { + this.cache.set(key, value) + } + + // Auto-subscribe to new keys + for (const key of uncachedKeys) { + if (!this.subscribedKeys.has(key)) { + await this.subscribeToKeyInternal(key) + } + } + + return { ...cachedResults, ...uncachedResults } + } catch (error) { + logger.error('Failed to get multiple preferences:', error as Error) + + // Fill in default values for failed keys + const defaultResults: Record = {} + for (const key of uncachedKeys) { + if (key in DefaultPreferences.default) { + defaultResults[key] = DefaultPreferences.default[key as PreferenceKey] + } + } + + return { ...cachedResults, ...defaultResults } + } + } + + return cachedResults + } + + /** + * Set multiple preferences at once + */ + async setMultiple(updates: Record): Promise { + try { + await window.api.preference.setMultiple(updates) + + // Update local cache for all updated values + for (const [key, value] of Object.entries(updates)) { + this.cache.set(key, value) + this.notifyListeners(key) + } + + logger.debug(`Updated ${Object.keys(updates).length} preferences`) + } catch (error) { + logger.error('Failed to set multiple preferences:', error as Error) + throw error + } + } + + /** + * Subscribe to a specific key for change notifications + */ + private async subscribeToKeyInternal(key: string): Promise { + if (!this.subscribedKeys.has(key)) { + try { + await window.api.preference.subscribe([key]) + this.subscribedKeys.add(key) + logger.debug(`Subscribed to preference key: ${key}`) + } catch (error) { + logger.error(`Failed to subscribe to preference key ${key}:`, error as Error) + } + } + } + + /** + * Subscribe to global preference changes (for useSyncExternalStore) + */ + subscribe = (callback: () => void): (() => void) => { + this.listeners.add(callback) + return () => { + this.listeners.delete(callback) + } + } + + /** + * Subscribe to specific key changes (for useSyncExternalStore) + */ + subscribeToKey = + (key: string) => + (callback: () => void): (() => void) => { + if (!this.keyListeners.has(key)) { + this.keyListeners.set(key, new Set()) + } + + const keyListeners = this.keyListeners.get(key)! + keyListeners.add(callback) + + // Auto-subscribe to this key for updates + this.subscribeToKeyInternal(key) + + return () => { + keyListeners.delete(callback) + if (keyListeners.size === 0) { + this.keyListeners.delete(key) + } + } + } + + /** + * Get snapshot for useSyncExternalStore + */ + getSnapshot = + (key: K) => + (): PreferencesType['default'][K] | undefined => { + return this.cache.get(key) + } + + /** + * Get cached value without async fetch + */ + getCachedValue(key: K): PreferencesType['default'][K] | undefined { + return this.cache.get(key) + } + + /** + * Check if a preference is cached + */ + isCached(key: string): boolean { + return this.cache.has(key) + } + + /** + * Preload specific preferences into cache + */ + async preload(keys: string[]): Promise { + const uncachedKeys = keys.filter((key) => !this.isCached(key)) + + if (uncachedKeys.length > 0) { + try { + const values = await this.getMultiple(uncachedKeys) + logger.debug(`Preloaded ${Object.keys(values).length} preferences`) + } catch (error) { + logger.error('Failed to preload preferences:', error as Error) + } + } + } + + /** + * Clear all cached preferences (for testing/debugging) + */ + clearCache(): void { + this.cache.clear() + logger.debug('Preference cache cleared') + } + + /** + * Get cache statistics (for debugging) + */ + getCacheStats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()) + } + } + + /** + * Cleanup service (call when shutting down) + */ + cleanup(): void { + if (this.changeListenerCleanup) { + this.changeListenerCleanup() + this.changeListenerCleanup = null + } + this.clearCache() + this.listeners.clear() + this.keyListeners.clear() + this.subscribedKeys.clear() + } +} + +// Export singleton instance +export const preferenceService = PreferenceService.getInstance() +export default preferenceService diff --git a/src/renderer/src/data/hooks/usePreference.ts b/src/renderer/src/data/hooks/usePreference.ts new file mode 100644 index 0000000000..96c88f5c1c --- /dev/null +++ b/src/renderer/src/data/hooks/usePreference.ts @@ -0,0 +1,154 @@ +import { loggerService } from '@logger' +import type { PreferencesType } from '@shared/data/preferences' +import { useCallback, useEffect, useSyncExternalStore } from 'react' + +import { preferenceService } from '../PreferenceService' + +const logger = loggerService.withContext('usePreference') + +type PreferenceKey = keyof PreferencesType['default'] + +/** + * React hook for managing a single preference value + * Uses useSyncExternalStore for optimal React 18 integration + * + * @param key - The preference key to manage + * @returns [value, setValue] - Current value and setter function + */ +export function usePreference( + key: K +): [PreferencesType['default'][K] | undefined, (value: PreferencesType['default'][K]) => Promise] { + // Subscribe to changes for this specific preference + const value = useSyncExternalStore( + preferenceService.subscribeToKey(key), + preferenceService.getSnapshot(key), + () => undefined // SSR snapshot (not used in Electron context) + ) + + // Load initial value asynchronously if not cached + useEffect(() => { + if (value === undefined && !preferenceService.isCached(key)) { + preferenceService.get(key).catch((error) => { + logger.error(`Failed to load initial preference ${key}:`, error as Error) + }) + } + }, [key, value]) + + // Memoized setter function + const setValue = useCallback( + async (newValue: PreferencesType['default'][K]) => { + try { + await preferenceService.set(key, newValue) + } catch (error) { + logger.error(`Failed to set preference ${key}:`, error as Error) + throw error + } + }, + [key] + ) + + return [value, setValue] +} + +/** + * React hook for managing multiple preference values + * Efficiently batches operations and provides type-safe interface + * + * @param keys - Object mapping local names to preference keys + * @returns [values, updateValues] - Current values and batch update function + */ +export function usePreferences>( + keys: T +): [ + { [P in keyof T]: PreferencesType['default'][T[P]] | undefined }, + (updates: Partial<{ [P in keyof T]: PreferencesType['default'][T[P]] }>) => Promise +] { + // Track changes to any of the specified keys + const keyList = Object.values(keys) + const keyListString = keyList.join(',') + const allValues = useSyncExternalStore( + useCallback( + (callback) => { + // Subscribe to all keys and aggregate the unsubscribe functions + const unsubscribeFunctions = keyList.map((key) => preferenceService.subscribeToKey(key)(callback)) + + return () => { + unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()) + } + }, + [keyList] + ), + + useCallback(() => { + // Return current snapshot of all values + const snapshot: Record = {} + for (const [localKey, prefKey] of Object.entries(keys)) { + snapshot[localKey] = preferenceService.getCachedValue(prefKey) + } + return snapshot + }, [keys]), + + () => ({}) // SSR snapshot + ) + + // Load initial values asynchronously if not cached + useEffect(() => { + const uncachedKeys = keyList.filter((key) => !preferenceService.isCached(key)) + + if (uncachedKeys.length > 0) { + preferenceService.getMultiple(uncachedKeys).catch((error) => { + logger.error('Failed to load initial preferences:', error as Error) + }) + } + }, [keyList, keyListString]) + + // Memoized batch update function + const updateValues = useCallback( + async (updates: Partial<{ [P in keyof T]: PreferencesType['default'][T[P]] }>) => { + try { + // Convert local keys back to preference keys + const prefUpdates: Record = {} + for (const [localKey, value] of Object.entries(updates)) { + const prefKey = keys[localKey as keyof T] + if (prefKey) { + prefUpdates[prefKey] = value + } + } + + await preferenceService.setMultiple(prefUpdates) + } catch (error) { + logger.error('Failed to update preferences:', error as Error) + throw error + } + }, + [keys] + ) + + // Type-cast the values to the expected shape + const typedValues = allValues as { [P in keyof T]: PreferencesType['default'][T[P]] | undefined } + + return [typedValues, updateValues] +} + +/** + * Hook for preloading preferences to improve performance + * Useful for components that will use many preferences + * + * @param keys - Array of preference keys to preload + */ +export function usePreferencePreload(keys: PreferenceKey[]): void { + const keysString = keys.join(',') + useEffect(() => { + preferenceService.preload(keys).catch((error) => { + logger.error('Failed to preload preferences:', error as Error) + }) + }, [keys, keysString]) +} + +/** + * Hook for getting the preference service instance + * Useful for non-reactive operations or advanced usage + */ +export function usePreferenceService() { + return preferenceService +}