feat(preferences): add IPC channels and handlers for preference management

This commit introduces new IPC channels for getting, setting, and subscribing to preferences, enhancing the application's ability to manage user preferences. It also updates the preferences interface to use a consistent naming convention and refactors the preference seeding and migration processes to align with these changes. Additionally, the PrefService has been removed as it is no longer needed.
This commit is contained in:
fullex 2025-08-11 20:40:50 +08:00
parent 54449e7130
commit 81538d5709
10 changed files with 845 additions and 7 deletions

View File

@ -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',

View File

@ -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,

View File

@ -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<number, Set<string>>() // windowId -> Set<keys>
private cache: Record<string, any> = { ...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<void> {
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<K extends PreferenceKey>(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<K extends PreferenceKey>(key: K, value: PreferencesType['default'][K]): Promise<void> {
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<string, any> {
if (!this.initialized) {
logger.warn('Preference cache not initialized, returning defaults for multiple keys')
const output: Record<string, any> = {}
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<string, any> = {}
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<string, any>): Promise<void> {
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<void> {
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<number, Set<string>> {
return new Map(this.subscriptions)
}
}
// Export singleton instance
export const preferenceService = PreferenceService.getInstance()
export default preferenceService

View File

@ -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}`

View File

@ -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,

View File

@ -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<string, any>) => {
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)
}
})
}

View File

@ -1 +0,0 @@
class PrefService {}

View File

@ -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: <K extends keyof PreferencesType['default']>(key: K) => ipcRenderer.invoke(IpcChannel.Preference_Get, key),
set: <K extends keyof PreferencesType['default']>(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<string, any>) => 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)
}
}
}

View File

@ -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<string, any>()
private listeners = new Set<() => void>()
private keyListeners = new Map<string, Set<() => void>>()
private changeListenerCleanup: (() => void) | null = null
private subscribedKeys = new Set<string>()
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<K extends PreferenceKey>(key: K): Promise<PreferencesType['default'][K]> {
// 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<K extends PreferenceKey>(key: K, value: PreferencesType['default'][K]): Promise<void> {
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<Record<string, any>> {
// Check which keys are already cached
const cachedResults: Record<string, any> = {}
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<string, any> = {}
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<string, any>): Promise<void> {
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<void> {
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 =
<K extends PreferenceKey>(key: K) =>
(): PreferencesType['default'][K] | undefined => {
return this.cache.get(key)
}
/**
* Get cached value without async fetch
*/
getCachedValue<K extends PreferenceKey>(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<void> {
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

View File

@ -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<K extends PreferenceKey>(
key: K
): [PreferencesType['default'][K] | undefined, (value: PreferencesType['default'][K]) => Promise<void>] {
// 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<T extends Record<string, PreferenceKey>>(
keys: T
): [
{ [P in keyof T]: PreferencesType['default'][T[P]] | undefined },
(updates: Partial<{ [P in keyof T]: PreferencesType['default'][T[P]] }>) => Promise<void>
] {
// 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<string, any> = {}
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<string, any> = {}
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
}