From e02c3dceb5c3eb2ed18a1be028ff8817448981ac Mon Sep 17 00:00:00 2001 From: Maofeng Date: Sun, 20 Sep 2026 15:52:03 +0800 Subject: [PATCH] feat(preferences): add extensible preference store --- packages/preferences/package.json | 25 ++ packages/preferences/src/index.test.tsx | 122 ++++++++ packages/preferences/src/index.tsx | 363 ++++++++++++++++++++++++ packages/preferences/tsconfig.json | 18 ++ 4 files changed, 528 insertions(+) create mode 100644 packages/preferences/package.json create mode 100644 packages/preferences/src/index.test.tsx create mode 100644 packages/preferences/src/index.tsx create mode 100644 packages/preferences/tsconfig.json diff --git a/packages/preferences/package.json b/packages/preferences/package.json new file mode 100644 index 0000000..9f2456b --- /dev/null +++ b/packages/preferences/package.json @@ -0,0 +1,25 @@ +{ + "name": "@workspace/preferences", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^19.2.6" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "jsdom": "^30.0.1", + "react-dom": "^19.2.6", + "typescript": "~6", + "vitest": "^4.1.10" + }, + "exports": { + ".": "./src/index.tsx" + } +} \ No newline at end of file diff --git a/packages/preferences/src/index.test.tsx b/packages/preferences/src/index.test.tsx new file mode 100644 index 0000000..ba4ea45 --- /dev/null +++ b/packages/preferences/src/index.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import React from "react" +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + createDefaultPreferences, + createPreferenceUpdateGuard, + PreferencesProvider, + usePreference, + type PreferenceDefinitions, + type PreferenceEffect, + type PreferenceEffectContext, + type Preferences, +} from "@workspace/preferences" + +declare module "@workspace/preferences" { + interface PreferencesCustom { + "main-compacted": boolean + "theme-mode": "light" | "dark" | "system" + } +} + +const definitions: PreferenceDefinitions = { + "main-compacted": { + cookie: "ui-main-compacted", + defaultValue: false, + is: (value): value is boolean => typeof value === "boolean", + parse: (value) => value === "1", + serialize: (value) => (value ? "1" : "0"), + }, + "theme-mode": { + cookie: "ui-theme", + defaultValue: "system", + is: (value): value is "light" | "dark" | "system" => + value === "light" || value === "dark" || value === "system", + parse: (value) => + value === "light" || value === "dark" || value === "system" + ? value + : "system", + serialize: (value) => value, + }, +} + +const initialPreferences = createDefaultPreferences(definitions) + +afterEach(cleanup) + +function PreferenceProbe() { + const [isCompacted, setCompacted] = usePreference("main-compacted") + + return ( + + ) +} + +describe("PreferencesProvider", () => { + it("runs composable effects and persists typed updates", async () => { + const layoutEffect = vi.fn((_context: PreferenceEffectContext) => undefined) + const effect = vi.fn((_context: PreferenceEffectContext) => undefined) + const onPreferenceChange = vi.fn() + const effects = [ + { effect, layoutEffect }, + ] satisfies readonly PreferenceEffect[] + + render( + + + + ) + + expect(screen.getByRole("button")).toHaveProperty("textContent", "expanded") + expect(layoutEffect).toHaveBeenCalledTimes(1) + await waitFor(() => expect(effect).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByRole("button")) + + expect(screen.getByRole("button")).toHaveProperty( + "textContent", + "compacted" + ) + expect(onPreferenceChange).toHaveBeenCalledWith({ + key: "main-compacted", + value: true, + }) + expect(layoutEffect).toHaveBeenCalledTimes(2) + expect( + layoutEffect.mock.calls[1]?.[0].previousPreferences["main-compacted"] + ).toBe(false) + expect(layoutEffect.mock.calls[1]?.[0].preferences["main-compacted"]).toBe( + true + ) + await waitFor(() => expect(effect).toHaveBeenCalledTimes(2)) + }) + + it("creates defaults and validates external updates", () => { + const isPreferenceUpdate = createPreferenceUpdateGuard(definitions) + + expect(initialPreferences).toEqual({ + "main-compacted": false, + "theme-mode": "system", + }) + expect(isPreferenceUpdate({ key: "theme-mode", value: "dark" })).toBe(true) + expect(isPreferenceUpdate({ key: "theme-mode", value: "unknown" })).toBe( + false + ) + expect(isPreferenceUpdate({ key: "missing", value: true })).toBe(false) + }) +}) diff --git a/packages/preferences/src/index.tsx b/packages/preferences/src/index.tsx new file mode 100644 index 0000000..66cf8fb --- /dev/null +++ b/packages/preferences/src/index.tsx @@ -0,0 +1,363 @@ +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]?.() + } + } +} diff --git a/packages/preferences/tsconfig.json b/packages/preferences/tsconfig.json new file mode 100644 index 0000000..d3bf6c5 --- /dev/null +++ b/packages/preferences/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "paths": { + "@workspace/preferences": ["./src/index.tsx"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file