feat(preferences): enhance preference subscription and notification system

- Updated `PreferenceService` to notify change listeners immediately upon fetching uncached values.
- Refactored subscription logic to simplify auto-subscribing to preference keys.
- Modified `usePreference` and `useMultiplePreferences` hooks to improve handling of default values and subscription behavior.
- Enhanced `SelectionAssistantSettings` to include detailed logging of preference states for better debugging.
This commit is contained in:
fullex 2025-08-15 18:27:36 +08:00
parent 9bde833419
commit 3dd2bc1a40
4 changed files with 71 additions and 35 deletions

View File

@ -105,10 +105,11 @@ export class PreferenceService {
const value = await window.api.preference.get(key)
this.cache[key] = value
// since not cached, notify change listeners to receive the value
this.notifyChangeListeners(key)
// Auto-subscribe to this key for future updates
if (!this.subscribedKeys.has(key)) {
await this.subscribeToKeyInternal(key)
}
return value
} catch (error) {
@ -226,13 +227,10 @@ export class PreferenceService {
// Update cache with new results
for (const [key, value] of Object.entries(uncachedResults)) {
this.cache[key as PreferenceKeyType] = value
}
// Auto-subscribe to new keys
for (const key of uncachedKeys) {
if (!this.subscribedKeys.has(key)) {
await this.subscribeToKeyInternal(key)
}
this.notifyChangeListeners(key)
await this.subscribeToKeyInternal(key as PreferenceKeyType)
}
return { ...cachedResults, ...uncachedResults }
@ -346,7 +344,8 @@ export class PreferenceService {
* Subscribe to a specific key for change notifications
*/
private async subscribeToKeyInternal(key: PreferenceKeyType): Promise<void> {
if (!this.subscribedKeys.has(key)) {
if (this.subscribedKeys.has(key)) return
try {
await window.api.preference.subscribe([key])
this.subscribedKeys.add(key)
@ -355,7 +354,6 @@ export class PreferenceService {
logger.error(`Failed to subscribe to preference key ${key}:`, error as Error)
}
}
}
/**
* Subscribe to global preference changes (for useSyncExternalStore)
@ -370,7 +368,7 @@ export class PreferenceService {
/**
* Subscribe to specific key changes (for useSyncExternalStore)
*/
public subscribeKeyChange =
public subscribeChange =
(key: PreferenceKeyType) =>
(callback: () => void): (() => void) => {
if (!this.keyChangeListeners.has(key)) {
@ -417,11 +415,12 @@ export class PreferenceService {
for (const [key, value] of Object.entries(allPreferences)) {
this.cache[key as PreferenceKeyType] = value
// Notify change listeners for the loaded value
this.notifyChangeListeners(key)
// Auto-subscribe to this key if not already subscribed
if (!this.subscribedKeys.has(key)) {
await this.subscribeToKeyInternal(key as PreferenceKeyType)
}
}
this.fullCacheLoaded = true
logger.info(`Loaded all ${Object.keys(allPreferences).length} preferences into cache`)

View File

@ -1,5 +1,6 @@
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { DefaultPreferences } from '@shared/data/preferences'
import type { PreferenceDefaultScopeType, PreferenceKeyType, PreferenceUpdateOptions } from '@shared/data/types'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
@ -81,22 +82,26 @@ const logger = loggerService.withContext('usePreference')
export function usePreference<K extends PreferenceKeyType>(
key: K,
options: PreferenceUpdateOptions = { optimistic: true }
): [PreferenceDefaultScopeType[K] | undefined, (value: PreferenceDefaultScopeType[K]) => Promise<void>] {
// Subscribe to changes for this specific preference
const value = useSyncExternalStore(
useCallback((callback) => preferenceService.subscribeKeyChange(key)(callback), [key]),
): [PreferenceDefaultScopeType[K], (value: PreferenceDefaultScopeType[K]) => Promise<void>] {
// Subscribe to changes for this specific preference (raw value including undefined)
const rawValue = useSyncExternalStore(
useCallback((callback) => preferenceService.subscribeChange(key)(callback), [key]),
useCallback(() => preferenceService.getCachedValue(key), [key]),
() => undefined // SSR snapshot (not used in Electron context)
)
// Load initial value asynchronously if not cached
useEffect(() => {
if (value === undefined && !preferenceService.isCached(key)) {
if (rawValue === undefined) {
preferenceService.get(key).catch((error) => {
logger.error(`Failed to load initial preference ${key}:`, error as Error)
})
}
}, [key, value])
}, [key, rawValue])
// Convert undefined to default value for external consumption
const exposedValue =
rawValue !== undefined ? rawValue : (DefaultPreferences.default[key] as PreferenceDefaultScopeType[K])
// Memoized setter function
const setValue = useCallback(
@ -111,7 +116,7 @@ export function usePreference<K extends PreferenceKeyType>(
[key, options]
)
return [value, setValue]
return [exposedValue, setValue]
}
/**
@ -247,7 +252,7 @@ export function useMultiplePreferences<T extends Record<string, PreferenceKeyTyp
keys: T,
options: PreferenceUpdateOptions = { optimistic: true }
): [
{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] | undefined },
{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] },
(updates: Partial<{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] }>) => Promise<void>
] {
// Create stable key dependencies
@ -256,11 +261,11 @@ export function useMultiplePreferences<T extends Record<string, PreferenceKeyTyp
// Cache the last snapshot to avoid infinite loops
const lastSnapshotRef = useRef<Record<string, any>>({})
const allValues = useSyncExternalStore(
const rawValues = useSyncExternalStore(
useCallback(
(callback: () => void) => {
// Subscribe to all keys and aggregate the unsubscribe functions
const unsubscribeFunctions = keyList.map((key) => preferenceService.subscribeKeyChange(key)(callback))
const unsubscribeFunctions = keyList.map((key) => preferenceService.subscribeChange(key)(callback))
return () => {
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
@ -296,14 +301,31 @@ export function useMultiplePreferences<T extends Record<string, PreferenceKeyTyp
// Load initial values asynchronously if not cached
useEffect(() => {
const uncachedKeys = keyList.filter((key) => !preferenceService.isCached(key))
// Find keys that need loading (either not cached or rawValue is undefined)
const uncachedKeys = keyList.filter((key) => {
// Find the local key for this preference key
const localKey = Object.keys(keys).find((k) => keys[k] === key)
const rawValue = localKey ? rawValues[localKey] : undefined
return rawValue === undefined && !preferenceService.isCached(key)
})
if (uncachedKeys.length > 0) {
preferenceService.getMultiple(uncachedKeys).catch((error) => {
logger.error('Failed to load initial preferences:', error as Error)
})
}
}, [keyList])
}, [keyList, rawValues, keys])
// Convert raw values (including undefined) to exposed values (with defaults)
const exposedValues = useMemo(() => {
const result: Record<string, any> = {}
for (const [localKey, prefKey] of Object.entries(keys)) {
const rawValue = rawValues[localKey]
result[localKey] = rawValue !== undefined ? rawValue : DefaultPreferences.default[prefKey]
}
return result
}, [keys, rawValues])
// Memoized batch update function
const updateValues = useCallback(
@ -328,7 +350,7 @@ export function useMultiplePreferences<T extends Record<string, PreferenceKeyTyp
)
// Type-cast the values to the expected shape
const typedValues = allValues as { [P in keyof T]: PreferenceDefaultScopeType[T[P]] | undefined }
const typedValues = exposedValues as { [P in keyof T]: PreferenceDefaultScopeType[T[P]] }
return [typedValues, updateValues]
}

View File

@ -1,4 +1,5 @@
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import { isMac, isWin } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { getSelectionDescriptionLabel } from '@renderer/i18n/label'
@ -25,6 +26,8 @@ import MacProcessTrustHintModal from './components/MacProcessTrustHintModal'
import SelectionActionsList from './components/SelectionActionsList'
import SelectionFilterListModal from './components/SelectionFilterListModal'
const logger = loggerService.withContext('Settings:SelectionAssistant')
const SelectionAssistantSettings: FC = () => {
const { theme } = useTheme()
const { t } = useTranslation()
@ -65,6 +68,18 @@ const SelectionAssistantSettings: FC = () => {
const [filterList, setFilterList] = usePreference('feature.selection.filter_list')
const [actionItems, setActionItems] = usePreference('feature.selection.action_items')
logger.debug(`selectionEnabled: ${selectionEnabled}`)
logger.debug(`triggerMode: ${triggerMode}`)
logger.debug(`isCompact: ${isCompact}`)
logger.debug(`isAutoClose: ${isAutoClose}`)
logger.debug(`isAutoPin: ${isAutoPin}`)
logger.debug(`isFollowToolbar: ${isFollowToolbar}`)
logger.debug(`isRemeberWinSize: ${isRemeberWinSize}`)
logger.debug(`actionWindowOpacity: ${actionWindowOpacity}`)
logger.debug(`filterMode: ${filterMode}`)
logger.debug(`filterList: ${filterList}`)
logger.debug(`actionItems: ${actionItems}`)
const isSupportedOS = isWin || isMac
const [isFilterListModalOpen, setIsFilterListModalOpen] = useState(false)

View File

@ -36,7 +36,7 @@ const PreferenceHookTests: React.FC = () => {
const testSubscriptions = () => {
// Test subscription behavior
const unsubscribe = preferenceService.subscribeKeyChange('app.theme.mode')(() => {
const unsubscribe = preferenceService.subscribeChange('app.theme.mode')(() => {
setSubscriptionCount((prev) => prev + 1)
})