import React from "react" /** * 应用自定义偏好的类型扩展入口。 * * 消费方可以使用 TypeScript 模块增强添加偏好,而不需要修改此 package: * * ```ts * import "@workspace/preferences" * * declare module "@workspace/preferences" { * interface PreferencesCustom { * "navigation-density": "comfortable" | "compact" * } * } * ``` */ export interface PreferencesCustom {} /** * Provider 管理的完整偏好集合。 * * 独立 package 不预设业务字段;所有字段均由消费方通过 `PreferencesCustom` 声明。 * 模块增强后,键、值、更新事件和 hooks 会自动获得相应类型。 */ export type Preferences = PreferencesCustom /** 完整偏好集合中的任意键。 */ export type PreferenceKey = keyof Preferences /** 根据偏好键取得对应值类型。 */ export type PreferenceValue = Preferences[Key] /** * 单个偏好字段的持久化与运行时校验规则。 * * `PreferencesProvider` 本身不读写 Cookie;该定义供服务器加载初始偏好、持久化 * 更新和校验未知输入时复用。 */ export type PreferenceDefinition = { /** 保存该字段时使用的 Cookie 名称。 */ cookie: string /** Cookie 不存在或内容无效时使用的默认值。 */ defaultValue: T /** 验证未知输入是否是当前字段允许的值。 */ is: (value: unknown) => value is T /** 将 Cookie 字符串解析为偏好值。 */ parse: (value: string | undefined) => T /** 将偏好值序列化为可写入 Cookie 的字符串。 */ serialize: (value: T) => string } /** 确保偏好集合中的每个字段都有相同值类型的定义。 */ export type PreferenceDefinitions = { [K in keyof TPreferences]: PreferenceDefinition } /** * 将偏好集合转换为由 `{ key, value }` 组成的可辨识联合类型。 * * 例如 `{ enabled: boolean; mode: "a" | "b" }` 会得到: * `{ key: "enabled"; value: boolean } | { key: "mode"; value: "a" | "b" }`。 */ export type PreferenceUpdateFor = { [K in keyof TPreferences]: { key: K; value: TPreferences[K] } }[keyof TPreferences] /** 当前完整偏好集合允许发送给持久化处理器的更新。 */ export type PreferenceUpdate = PreferenceUpdateFor /** * 外部偏好存储的最小接口。 * * 它符合 `useSyncExternalStore` 对订阅、客户端快照和服务端快照的要求。 */ export type PreferenceStore = { /** 返回当前最新偏好;内容未变化时必须保持引用稳定。 */ getSnapshot: () => TPreferences /** 返回 SSR 与首次 hydration 使用的偏好。 */ getServerSnapshot: () => TPreferences /** 更新一个偏好;实际发生变化时返回 `true`。 */ setPreference: ( key: K, value: TPreferences[K] ) => boolean /** 订阅偏好变化,并返回取消订阅函数。 */ subscribe: (listener: VoidFunction) => VoidFunction } /** 每个偏好副作用收到的只读上下文。 */ export type PreferenceEffectContext = Readonly<{ /** * 上一次执行同阶段副作用时的快照。 * 首次执行时与 `preferences` 相同,因此无需处理 `undefined`。 */ previousPreferences: Preferences /** 当前偏好快照。 */ preferences: Preferences /** 可用于事件回调中读取最新偏好的稳定 store 实例。 */ store: PreferenceStore }> /** 偏好副作用回调;返回函数时,该函数会作为 React effect cleanup 执行。 */ export type PreferenceEffectCallback = ( context: PreferenceEffectContext ) => VoidFunction | void /** * 可注入 `PreferencesProvider` 的副作用集合。 * * - `layoutEffect` 在 DOM 更新后、浏览器绘制前运行,适合同步 class、属性和 CSS。 * - `effect` 在绘制后运行,适合事件监听、媒体查询和其他外部订阅。 * * 两种回调都会在偏好或 `effects` 引用变化时重新执行。调用方应传入稳定的 * `effects` 数组,例如定义在组件外部或通过 `useMemo` 创建。 */ export type PreferenceEffect = Readonly<{ effect?: PreferenceEffectCallback layoutEffect?: PreferenceEffectCallback }> /** * 创建单个 Provider 私有的外部 store。 * * `initialPreferences` 同时作为固定的服务端快照,确保 SSR 与 hydration 读取一致。 */ function createPreferenceStore( initialPreferences: Preferences ): PreferenceStore { let snapshot = initialPreferences const listeners = new Set() return { getSnapshot: () => snapshot, getServerSnapshot: () => initialPreferences, setPreference: (key, value) => { // Object.is 可以正确处理 NaN,并避免等值更新触发无意义的渲染和持久化。 if (Object.is(snapshot[key], value)) return false // 始终生成新对象,使 useSyncExternalStore 能通过引用识别偏好变化。 snapshot = { ...snapshot, [key]: value } listeners.forEach((listener) => listener()) return true }, subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) }, } } /** * 偏好持久化回调。允许同步或异步实现,例如写 Cookie、调用服务端函数或发送请求。 */ export type PreferenceChangeHandler = ( update: PreferenceUpdate ) => Promise | void /** Provider 向所有偏好 hooks 暴露的内部上下文。 */ interface PreferencesContextValue { onPreferenceChange?: PreferenceChangeHandler store: PreferenceStore } const PreferencesContext = React.createContext( null ) // 使用模块级空数组保持默认 effects 引用稳定,避免每次渲染重复执行副作用。 const noPreferenceEffects: readonly PreferenceEffect[] = [] /** `PreferencesProvider` 的属性。 */ export type PreferencesProviderProps = { children: React.ReactNode /** 按数组顺序执行的偏好副作用。 */ effects?: readonly PreferenceEffect[] /** * SSR 与客户端首次渲染使用的完整偏好。 * Store 只在 Provider 首次渲染时创建,之后修改该属性不会重置已有偏好。 */ initialPreferences: Preferences /** 偏好实际变化后触发的持久化回调。 */ onPreferenceChange?: PreferenceChangeHandler } /** 提供类型安全的外部偏好存储,并运行与偏好关联的可组合副作用。 */ export function PreferencesProvider({ children, effects = noPreferenceEffects, initialPreferences, onPreferenceChange, }: PreferencesProviderProps) { const storeRef = React.useRef(null) if (storeRef.current === null) { storeRef.current = createPreferenceStore(initialPreferences) } const store = storeRef.current const preferences = React.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getServerSnapshot ) // 两个 React effect 的执行时机不同,因此分别保存各自上一次看到的偏好。 const previousLayoutPreferencesRef = React.useRef(preferences) const previousPreferencesRef = React.useRef(preferences) // 适合在浏览器绘制前,把偏好同步到 document、DOM 或 CSS。 React.useLayoutEffect(() => { const previousPreferences = previousLayoutPreferencesRef.current previousLayoutPreferencesRef.current = preferences return runPreferenceEffects(effects, "layoutEffect", { previousPreferences, preferences, store, }) }, [effects, preferences, store]) // 适合注册外部订阅;runPreferenceEffects 会统一收集并执行 cleanup。 React.useEffect(() => { const previousPreferences = previousPreferencesRef.current previousPreferencesRef.current = preferences return runPreferenceEffects(effects, "effect", { previousPreferences, preferences, store, }) }, [effects, preferences, store]) const contextValue = React.useMemo( () => ({ onPreferenceChange, store }), [onPreferenceChange, store] ) return ( {children} ) } /** * 订阅并返回完整偏好快照。 * * 任意字段变化都会使使用该 hook 的组件重新渲染;只需要单个字段时可以使用 * `usePreference` 获得更精确的类型和更新 API。当前两个 hook 都订阅完整快照。 */ export function usePreferences() { const { store } = usePreferencesContext() return React.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getServerSnapshot ) } /** * 读取并更新一个指定偏好,返回值形式与 `useState` 一致。 * * setter 同时支持直接值和函数式更新。只有值真正变化时才会通知订阅者并调用 * `onPreferenceChange`;异步持久化结果不会阻塞本地更新。 */ export function usePreference( key: Key ): [ PreferenceValue, React.Dispatch>>, ] { const { onPreferenceChange, store } = usePreferencesContext() const preferences = React.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getServerSnapshot ) const value = preferences[key] const setPreference = React.useCallback< React.Dispatch>> >( (action) => { const currentValue = store.getSnapshot()[key] const nextValue = typeof action === "function" ? action(currentValue) : action // 等值更新由 store 拒绝,因此也不会触发外部持久化处理器。 if (!store.setPreference(key, nextValue)) return void onPreferenceChange?.({ key, value: nextValue } as PreferenceUpdate) }, [key, onPreferenceChange, store] ) return [value, setPreference] } /** 根据字段定义生成完整的默认偏好对象。 */ export function createDefaultPreferences( definitions: PreferenceDefinitions ): TPreferences { return Object.fromEntries( (Object.keys(definitions) as Array).map((key) => [ key, definitions[key].defaultValue, ]) ) as TPreferences } /** * 根据字段定义创建运行时类型守卫,用于校验来自请求、表单或消息通道的未知更新。 */ export function createPreferenceUpdateGuard( definitions: PreferenceDefinitions ) { return (value: unknown): value is PreferenceUpdateFor => { if (!value || typeof value !== "object") return false const update = value as { key?: unknown; value?: unknown } if ( typeof update.key !== "string" || !Object.hasOwn(definitions, update.key) ) { return false } const key = update.key as keyof TPreferences return definitions[key].is(update.value) } } /** 读取内部上下文,并为 Provider 缺失的情况提供明确错误。 */ function usePreferencesContext() { const context = React.useContext(PreferencesContext) if (!context) { throw new Error("Preference hooks must be used within PreferencesProvider") } return context } /** * 按声明顺序执行指定阶段的所有副作用,并组合它们返回的 cleanup。 * cleanup 使用相反顺序运行,与嵌套资源通常采用的后进先出释放顺序一致。 */ function runPreferenceEffects( effects: readonly PreferenceEffect[], phase: "effect" | "layoutEffect", context: PreferenceEffectContext ) { const cleanups = effects .map((effect) => effect[phase]?.(context)) .filter((cleanup): cleanup is VoidFunction => typeof cleanup === "function") if (cleanups.length === 0) return return () => { for (let index = cleanups.length - 1; index >= 0; index -= 1) { cleanups[index]?.() } } }