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

View File

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

View File

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

View File

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