Files
deepseek-harness/packages/client/web-react/src/store/index.ts
imccyu 2431caa9ab fix: ci docs/lint
fix: ci docs/lint

ci: coverage
2026-07-22 17:03:10 +08:00

155 lines
5.8 KiB
TypeScript

/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze). The only data contract
* consumed by React is {@link ObservableSnapshot}.
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
import { produce } from 'immer'
import { bindSnapshotSelector } from '../bind.ts'
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
/** Writable snapshot store with an attached typed selector hook. */
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
/**
* Mutate the state through an immer draft.
* @param mutator - draft mutator.
*/
update(mutator: (draft: T) => void): void
/**
* Replace the state wholesale.
* @param next - next state.
*/
set(next: T): void
readonly useSelector: SnapshotSelectorHook<T>
}
/** Typed selector hook: equality defaults to Object.is; pass shallowEqual for object slices. */
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Shallow equality for selector slices (re-export of zustand/shallow semantics).
* @param a - left value.
* @param b - right value.
* @returns whether the values are shallowly equal.
*/
export function shallowEqual(a: unknown, b: unknown): boolean {
return shallow(a, b)
}
/** Batches subscriber notification into one flush per animation frame. */
function rafBatch(notify: () => void): () => void {
// Fall back to microtask batching where rAF is absent (node unit tests);
// both preserve the N-changes=1-notification contract within a tick.
const schedule: (fn: () => void) => void =
typeof requestAnimationFrame === 'function'
? (fn) => { requestAnimationFrame(() => { fn() }) }
: (fn) => { queueMicrotask(fn) }
let scheduled = false
return () => {
if (scheduled) return
scheduled = true
schedule(() => {
scheduled = false
notify()
})
}
}
/**
* Create a snapshot store.
*
* Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven
* stores opt into 'raf', where a frame's worth of updates coalesces into one
* notification. Known raf-mode tradeoff: a component mounting mid-frame reads
* fresh state while existing subscribers hear it next flush — transient
* frame-level skew, same nature as the object layer's microtask batching.
*
* @param init - initial state.
* @param opts - flush mode and opt-in persistence (localStorage, keyed by name).
* @returns the store.
*/
export function createSnapshotStore<T>(
init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {
// Immer enters through produce() in update() below (identical semantics to
// the immer middleware without its setState-signature mutator generics).
const withSelector = subscribeWithSelector(() => init)
const api: StoreApi<T> = createStore<T>()(withSelector)
if (opts?.persist) attachPersistence(api, opts.persist.name)
let subscribe = (fn: () => void) => api.subscribe(fn)
if (opts?.flush === 'raf') {
const listeners = new Set<() => void>()
const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
api.subscribe(flush)
subscribe = (fn: () => void) => {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
}
const store: SnapshotStore<T> = {
getSnapshot: () => api.getState(),
subscribe: fn => subscribe(fn),
update: (mutator) => {
// Immer's produce (not setState's partial-merge path) so scalar and
// array roots replace correctly; produce also freezes in dev.
api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)
},
set: (next) => {
api.setState(devFreeze(next), true)
},
useSelector: undefined as unknown as SnapshotSelectorHook<T>,
}
;(store as { useSelector: SnapshotSelectorHook<T> }).useSelector = bindSnapshotSelector(store)
return store
}
/**
* Whole-value JSON persistence to localStorage. Hand-rolled instead of the
* zustand persist middleware: its write path spreads state into an object
* (`partialize({ ...get() })`), exploding primitive state (a persisted string
* draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options
* because the corruption happens before serialization. Storage failures
* (quota, private mode) only disable persistence, never break the store.
*/
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
// Non-browser runs (node e2e booting the client tree) have no localStorage:
// persistence silently disables — same contract as a storage failure, minus
// the per-store console noise a ReferenceError would produce.
if (typeof localStorage === 'undefined') return
try {
const raw = localStorage.getItem(name)
if (raw !== null) {
api.setState(devFreeze(JSON.parse(raw) as T), true)
}
} catch (error) {
console.error(`snapshot store '${name}' rehydration failed:`, error)
}
api.subscribe((state) => {
try {
localStorage.setItem(name, JSON.stringify(state))
} catch (error) {
console.error(`snapshot store '${name}' persistence failed:`, error)
}
})
}
/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
function devFreeze<T>(value: T): T {
if (process.env.NODE_ENV === 'production') return value
deepFreeze(value)
return value
}
function deepFreeze(value: unknown): void {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
Object.freeze(value)
for (const key of Reflect.ownKeys(value)) {
deepFreeze((value as Record<PropertyKey, unknown>)[key])
}
}