index.ts 10 KB

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