返回源码地图

packages/client/store/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 16 / 16 / 16;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * React-free snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
3 * rafFlush middleware + opt-in persist + dev freeze) plus the declarative
4 * shell over it: {@link defineStore} bakes an init/persist/actions literal
5 * into a {@link StoreHandle}, the registration-side store seat of slot
6 * terminals. Engine products are bare observables — subscribe/getSnapshot/
7 * update/set, NO selector hook. Hook synthesis is ui-renderer's (the one
8 * uSES bridge, cached per source at the binding site).
9 */
10import { createStore, type StoreApi } from 'zustand/vanilla'
11import { subscribeWithSelector } from 'zustand/middleware'
12import { shallow } from 'zustand/shallow'
13import { freeze, produce } from 'immer'
14import type {
15 ActionsDecl, BakedActions, ObservableSnapshot, StoreHandle, StoreInstance, StoreSpec,
16} from './contract.ts'
17
18// Store contract types are ui-slots authority; re-exported beside the engine
19// so store consumers get one import path.
20export type {
21 ActionsDecl, BakedActions, BoundActions, DefineStore, HandleOf, MaybeSnapshotSelectorHook,
22 ObservableSnapshot, PropsStore, SnapshotSelectorHook, StoreDecl, StoreFactory,
23 StoreHandle, StoreInstance, StoreSpec,
24} from './contract.ts'
25
26/** Writable snapshot store (bare data face; React selector hooks are synthesized in ui-renderer). */
27export interface SnapshotStore<T> extends ObservableSnapshot<T> {
28 /**
29 * Mutate the state through an immer draft.
30 * @param mutator - draft mutator.
31 */
32 update(mutator: (draft: T) => void): void
33 /**
34 * Replace the state wholesale.
35 * @param next - next state.
36 */
37 set(next: T): void
38}
39
40/**
41 * Notify an observer set without allowing one callback to starve the rest.
42 * @param listeners - current observer callbacks; copied before dispatch.
43 * @param label - diagnostic owner prefix.
44 * @param args - callback arguments.
45 */
46export function notifySubscribers<Args extends readonly unknown[]>(
47 listeners: Iterable<(...args: Args) => void>,
48 label: string,
49 ...args: Args
50): void {
51 for (const listener of [...listeners]) {
52 try {
53 listener(...args)
54 } catch (error) {
55 console.error(`${label} subscriber failed:`, error)
56 }
57 }
58}
59
60/**
61 * Shallow equality for selector slices (zustand/shallow semantics; travels
62 * with the engine so hook consumers need no zustand dependency).
63 * @param a - left value.
64 * @param b - right value.
65 * @returns whether the values are shallowly equal.
66 */
67export function shallowEqual(a: unknown, b: unknown): boolean {
68 return shallow(a, b)
69}
70
71/** Batches subscriber notification into one flush per animation frame. */
72function rafBatch(notify: () => void): () => void {
73 // Fall back to microtask batching where rAF is absent (node unit tests);
74 // both preserve the N-changes=1-notification contract within a tick.
75 const schedule: (fn: () => void) => void =
76 typeof requestAnimationFrame === 'function'
77 ? (fn) => { requestAnimationFrame(() => { fn() }) }
78 : (fn) => { queueMicrotask(fn) }
79 let scheduled = false
80 return () => {
81 if (scheduled) return
82 scheduled = true
83 schedule(() => {
84 scheduled = false
85 notify()
86 })
87 }
88}
89
90/**
91 * Create a snapshot store.
92 *
93 * Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven
94 * stores opt into 'raf', where a frame's worth of updates coalesces into one
95 * notification. Known raf-mode tradeoff: a component mounting mid-frame reads
96 * fresh state while existing subscribers hear it next flush — transient
97 * frame-level skew, same nature as the object layer's microtask batching.
98 *
99 * @param init - initial state.
100 * @param opts - flush mode and opt-in persistence (localStorage, keyed by name).
101 * @returns the store.
102 */
103export function createSnapshotStore<T>(
104 init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {
105 // Immer enters through produce() in update() below (identical semantics to
106 // the immer middleware without its setState-signature mutator generics).
107 const withSelector = subscribeWithSelector(() => init)
108 const api: StoreApi<T> = createStore<T>()(withSelector)
109 if (opts?.persist) attachPersistence(api, opts.persist.name)
110
111 let subscribe = (fn: () => void) => api.subscribe(() => {
112 notifySubscribers([fn], '[client-store]')
113 })
114 if (opts?.flush === 'raf') {
115 const listeners = new Set<() => void>()
116 const flush = rafBatch(() => { notifySubscribers(listeners, '[client-store]') })
117 api.subscribe(flush)
118 subscribe = (fn: () => void) => {
119 listeners.add(fn)
120 return () => { listeners.delete(fn) }
121 }
122 }
123
124 return {
125 getSnapshot: () => api.getState(),
126 subscribe: fn => subscribe(fn),
127 update: (mutator) => {
128 // Immer's produce (not setState's partial-merge path) so scalar and
129 // array roots replace correctly; produce also freezes in dev.
130 api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)
131 },
132 set: (next) => {
133 api.setState(devFreeze(next), true)
134 },
135 }
136}
137
138/**
139 * Whole-value JSON persistence to localStorage. Hand-rolled instead of the
140 * zustand persist middleware: its write path spreads state into an object
141 * (`partialize({ ...get() })`), exploding primitive state (a persisted string
142 * draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options
143 * because the corruption happens before serialization. Storage failures
144 * (quota, private mode) only disable persistence, never break the store.
145 */
146function attachPersistence<T>(api: StoreApi<T>, name: string): void {
147 // Non-browser runs (node e2e booting the client tree) have no localStorage:
148 // persistence silently disables — same contract as a storage failure, minus
149 // the per-store console noise a ReferenceError would produce.
150 if (typeof localStorage === 'undefined') return
151 try {
152 const raw = localStorage.getItem(name)
153 if (raw !== null) {
154 api.setState(devFreeze(JSON.parse(raw) as T), true)
155 }
156 } catch (error) {
157 console.error(`snapshot store '${name}' rehydration failed:`, error)
158 }
159 api.subscribe((state) => {
160 try {
161 localStorage.setItem(name, JSON.stringify(state))
162 } catch (error) {
163 console.error(`snapshot store '${name}' persistence failed:`, error)
164 }
165 })
166}
167
168/** Deep-freeze draftable wholesale-set state outside production: set() bypasses immer's freeze. */
169function devFreeze<T>(value: T): T {
170 if (process.env.NODE_ENV === 'production') return value
171 return freeze(value, true)
172}
173
174// ui-slots owns the contract; this module supplies the engine implementation.
175
176/** A live engine instance: the contract instance plus the raw engine store. */
177export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
178 /** The underlying engine store (framework/test API; components never see it). */
179 readonly store: SnapshotStore<T>
180}
181
182/** The engine-backed handle: create() narrowed to the engine instance. */
183export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {
184 /**
185 * Construct a live engine instance (see the contract JSDoc on
186 * {@link StoreHandle.create} for scopeKey/persist semantics).
187 *
188 * Known boundary: the persist key is the storage identity, so multiple live
189 * instances created under the same resolved key share (and cross-pollute)
190 * one localStorage entry. Instance uniqueness per key is the caller's
191 * responsibility — production is safe because the framework caches one
192 * instance per handle x scope key; tests wanting isolation use distinct
193 * scope keys or persist-free declarations (multi-create freedom is a
194 * feature there, so create() deliberately does not dedupe or throw).
195 * @param scopeKey - session id for session-scope instances; omitted for root scope.
196 * @returns the engine instance.
197 */
198 create(scopeKey?: string): EngineStoreInstance<T, A>
199}
200
201/**
202 * Declare a store: initial state, optional persistence, and the full write
203 * set as pure draft mutators. The returned handle is the registration
204 * currency of the store seat — its identity keys instance sharing. Satisfies
205 * ui-slots' DefineStore contract (the handle/instance are the engine-extended
206 * subtypes).
207 *
208 * The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
209 * `init` in the first inference round, and the intersection then contextually
210 * types each mutator's draft parameter (context-sensitive functions defer),
211 * so call sites write `(d, x: X) => { ... }` with no draft annotation. If a
212 * future TS version breaks this single-literal inference, the design's
213 * documented fallback is currying (`defineStore(init).actions({...})`).
214 * @param decl - init lambda (fresh state per instance), optional persist key, actions table.
215 * @returns the store handle.
216 */
217export function defineStore<T, A extends ActionsDecl<T>>(
218 decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {
219 return {
220 spec: decl,
221 create(scopeKey?: string): EngineStoreInstance<T, A> {
222 const persistKey = decl.persist === undefined
223 ? undefined
224 : scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`
225 const store = createSnapshotStore<T>(
226 decl.init(),
227 persistKey !== undefined ? { persist: { name: persistKey } } : undefined)
228 const actions = {} as Record<string, (...params: unknown[]) => void>
229 for (const key of Object.keys(decl.actions)) {
230 const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void
231 actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
232 }
233 return {
234 actions: actions as BakedActions<T, A>,
235 getSnapshot: () => store.getSnapshot(),
236 subscribe: fn => store.subscribe(fn),
237 store,
238 clearPersisted: () => {
239 if (persistKey === undefined || typeof localStorage === 'undefined') return
240 try {
241 localStorage.removeItem(persistKey)
242 } catch {
243 // Storage failures (private mode, quota teardown races) only skip
244 // cleanup — the same non-fatal contract as attachPersistence.
245 }
246 },
247 }
248 },
249 }
250}