feat(preferences): add extensible preference store

This commit is contained in:
Maofeng
2026-09-20 15:52:03 +08:00
parent 2043c7d777
commit e02c3dceb5
4 changed files with 528 additions and 0 deletions
+122
View File
@@ -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<Preferences> = {
"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 (
<button type="button" onClick={() => setCompacted((value) => !value)}>
{isCompacted ? "compacted" : "expanded"}
</button>
)
}
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(
<PreferencesProvider
effects={effects}
initialPreferences={initialPreferences}
onPreferenceChange={onPreferenceChange}
>
<PreferenceProbe />
</PreferencesProvider>
)
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)
})
})
+363
View File
@@ -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<Key extends PreferenceKey> = Preferences[Key]
/**
* 单个偏好字段的持久化与运行时校验规则。
*
* `PreferencesProvider` 本身不读写 Cookie;该定义供服务器加载初始偏好、持久化
* 更新和校验未知输入时复用。
*/
export type PreferenceDefinition<T> = {
/** 保存该字段时使用的 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<TPreferences extends object> = {
[K in keyof TPreferences]: PreferenceDefinition<TPreferences[K]>
}
/**
* 将偏好集合转换为由 `{ key, value }` 组成的可辨识联合类型。
*
* 例如 `{ enabled: boolean; mode: "a" | "b" }` 会得到:
* `{ key: "enabled"; value: boolean } | { key: "mode"; value: "a" | "b" }`。
*/
export type PreferenceUpdateFor<TPreferences extends object> = {
[K in keyof TPreferences]: { key: K; value: TPreferences[K] }
}[keyof TPreferences]
/** 当前完整偏好集合允许发送给持久化处理器的更新。 */
export type PreferenceUpdate = PreferenceUpdateFor<Preferences>
/**
* 外部偏好存储的最小接口。
*
* 它符合 `useSyncExternalStore` 对订阅、客户端快照和服务端快照的要求。
*/
export type PreferenceStore<TPreferences extends object = Preferences> = {
/** 返回当前最新偏好;内容未变化时必须保持引用稳定。 */
getSnapshot: () => TPreferences
/** 返回 SSR 与首次 hydration 使用的偏好。 */
getServerSnapshot: () => TPreferences
/** 更新一个偏好;实际发生变化时返回 `true`。 */
setPreference: <K extends keyof TPreferences>(
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<VoidFunction>()
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> | void
/** Provider 向所有偏好 hooks 暴露的内部上下文。 */
interface PreferencesContextValue {
onPreferenceChange?: PreferenceChangeHandler
store: PreferenceStore
}
const PreferencesContext = React.createContext<PreferencesContextValue | null>(
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<PreferenceStore | null>(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 (
<PreferencesContext.Provider value={contextValue}>
{children}
</PreferencesContext.Provider>
)
}
/**
* 订阅并返回完整偏好快照。
*
* 任意字段变化都会使使用该 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 extends PreferenceKey>(
key: Key
): [
PreferenceValue<Key>,
React.Dispatch<React.SetStateAction<PreferenceValue<Key>>>,
] {
const { onPreferenceChange, store } = usePreferencesContext()
const preferences = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
)
const value = preferences[key]
const setPreference = React.useCallback<
React.Dispatch<React.SetStateAction<PreferenceValue<Key>>>
>(
(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<TPreferences extends object>(
definitions: PreferenceDefinitions<TPreferences>
): TPreferences {
return Object.fromEntries(
(Object.keys(definitions) as Array<keyof TPreferences>).map((key) => [
key,
definitions[key].defaultValue,
])
) as TPreferences
}
/**
* 根据字段定义创建运行时类型守卫,用于校验来自请求、表单或消息通道的未知更新。
*/
export function createPreferenceUpdateGuard<TPreferences extends object>(
definitions: PreferenceDefinitions<TPreferences>
) {
return (value: unknown): value is PreferenceUpdateFor<TPreferences> => {
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]?.()
}
}
}