refactor(gui): move the snapshot-store engine into the client runtime
The data layer no longer depends on the React glue package, and business plugins no longer depend on web-react at all: - The store engine (zustand vanilla + immer + persist + dev freeze), defineStore, and shallowEqual move to @deepseek-ai/dsh-client-runtime, exported from the ./client main entry — no ./store subpath survives on either package (the web-react one is deleted, none is opened on runtime). - Store products are bare snapshot sources: useSelector leaves SnapshotStore/StoreInstance and Session; every hook is composed at the binding site in web-react's renderer (per-source cached uSES binding). The SlotRendererHost sessions face carries bare observables only. - SessionProvider becomes a standard-kit seat: an entry whose children declare a session-scope slot receives the framework component as a prop, retiring the last value import of web-react from plugin packages. UseSession and the session-area types now live in ui-slots. - web-react shrinks to the shell-only React glue (renderer, providers, uSES bridge); zustand/immer belong to runtime alone; the module-table seed and tsdown externals drop the web-react/store seat. - NODE_ENV replacement is defined once in the shared tsdown client preset (browser bundles inline the engine and lost vite's define); the 3-line process.env typecheck shim moves to runtime with the engine. - Stray tsc artifacts (.js/.d.ts/.d.ts.map beside sources under src/) swept repo-wide; they shadow real sources under vitest resolution. Verified: both aggregate typecheck programs at zero; 604 client tests green; repo-wide grep for web-react/store at zero; real-host playwright run 7/7 including persist round-trip. ci: fix test/docs
This commit is contained in:
@@ -37,10 +37,11 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
"zustand": "~4.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SnapshotStore } from './store/index.ts'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
@@ -25,6 +25,13 @@ export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionsService, scopeOf } from './sessions/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
// The snapshot-store engine lives here since the store migration (the data
|
||||
// layer owns its substrate; web-react is React glue only). The './client'
|
||||
// main export is the single serving door — no store subpath.
|
||||
export { createSnapshotStore, defineStore, shallowEqual } from './store/index.ts'
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './store/index.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
PendingInteraction, RunningToolCall, SteeringMessageNode,
|
||||
@@ -45,7 +52,7 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
|
||||
export type UseConversationSession = UseSession<ConversationSnapshot>
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/**
|
||||
* One tool call as the chat flow renders it: still-running (spinner card) or
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* load one by one in inject topology.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import { createSnapshotStore } from '../store/index.ts'
|
||||
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
|
||||
|
||||
@@ -15,11 +15,9 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Engine reach-through: the store subpath is the framework-internal channel
|
||||
// (the public web-react face carries defineStore only).
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../store/index.ts'
|
||||
import { createSnapshotStore } from '../store/index.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -216,7 +214,9 @@ export class SessionsService {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session, ctx },
|
||||
cell: { sessionId: id, useSession: session.useSelector },
|
||||
// Bare source form (store migration): the Session object IS the
|
||||
// observable; the React side binds the useSession hook per cell.
|
||||
cell: { sessionId: id, session },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ObservableSnapshot } from '../store/index.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
@@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts'
|
||||
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
|
||||
/**
|
||||
* Per-session state owner: event window + fold + partial, snapshot out via
|
||||
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
|
||||
* only (store migration): the React machinery binds the per-cell useSession
|
||||
* hook at its own seam — no selector hook member lives on the data layer.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
|
||||
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
|
||||
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
private events: SessionEvent[] = []
|
||||
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
|
||||
|
||||
244
packages/client/runtime/src/client/store/index.ts
Normal file
244
packages/client/runtime/src/client/store/index.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
|
||||
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative
|
||||
* shell over it: {@link defineStore} bakes an init/persist/actions literal
|
||||
* into a {@link StoreHandle}, the registration-side store seat of the slot
|
||||
* terminal design (§4). Lives in the React-free runtime (store-migration
|
||||
* ruling: the data layer owns its engine; web-react is shell-only React
|
||||
* glue): engine products are bare observables — subscribe/getSnapshot/
|
||||
* update/set, NO selector hook. Hook synthesis is web-react's (the one
|
||||
* uSES bridge, cached per source at the binding site).
|
||||
*/
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Store contract types are ui-slots authority; re-exported beside the engine
|
||||
// so store consumers get one import surface.
|
||||
export type {
|
||||
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** 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 (bare data face; React selector hooks are synthesized in web-react). */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow equality for selector slices (zustand/shallow semantics; travels
|
||||
* with the engine so hook consumers need no zustand dependency).
|
||||
* @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) }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- defineStore shell (slot terminal design §4) ----
|
||||
// The type authority is ui-slots' store family (create(scopeKey?) and
|
||||
// clearPersisted() included); this module houses only the engine-backed
|
||||
// implementation. The one engine-side widening left: instances expose the
|
||||
// raw engine store for framework/test surfaces.
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
/** The underlying engine store (framework/test surface; components never see it). */
|
||||
readonly store: SnapshotStore<T>
|
||||
}
|
||||
|
||||
/** The engine-backed handle: create() narrowed to the engine instance. */
|
||||
export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {
|
||||
/**
|
||||
* Construct a live engine instance (see the contract JSDoc on
|
||||
* {@link StoreHandle.create} for scopeKey/persist semantics).
|
||||
*
|
||||
* Known boundary: the persist key is the storage identity, so multiple live
|
||||
* instances created under the same resolved key share (and cross-pollute)
|
||||
* one localStorage entry. Instance uniqueness per key is the caller's
|
||||
* responsibility — production is safe because the framework caches one
|
||||
* instance per handle x scope key; tests wanting isolation use distinct
|
||||
* scope keys or persist-free declarations (multi-create freedom is a
|
||||
* feature there, so create() deliberately does not dedupe or throw).
|
||||
* @param scopeKey - session id for session-scope instances; omitted for root scope.
|
||||
* @returns the engine instance.
|
||||
*/
|
||||
create(scopeKey?: string): EngineStoreInstance<T, A>
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a store: initial state, optional persistence, and the full write
|
||||
* set as pure draft mutators. The returned handle is the registration
|
||||
* currency of the store seat — its identity keys instance sharing. Satisfies
|
||||
* ui-slots' DefineStore contract (the handle/instance are the engine-extended
|
||||
* subtypes).
|
||||
*
|
||||
* The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
|
||||
* `init` in the first inference round, and the intersection then contextually
|
||||
* types each mutator's draft parameter (context-sensitive functions defer),
|
||||
* so call sites write `(d, x: X) => { ... }` with no draft annotation. If a
|
||||
* future TS version breaks this single-literal inference, the design's
|
||||
* documented fallback is currying (`defineStore(init).actions({...})`).
|
||||
* @param decl - init lambda (fresh state per instance), optional persist key, actions table.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function defineStore<T, A extends ActionsDecl<T>>(
|
||||
decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {
|
||||
return {
|
||||
spec: decl,
|
||||
create(scopeKey?: string): EngineStoreInstance<T, A> {
|
||||
const persistKey = decl.persist === undefined
|
||||
? undefined
|
||||
: scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`
|
||||
const store = createSnapshotStore<T>(
|
||||
decl.init(),
|
||||
persistKey !== undefined ? { persist: { name: persistKey } } : undefined)
|
||||
const actions = {} as Record<string, (...params: unknown[]) => void>
|
||||
for (const key of Object.keys(decl.actions)) {
|
||||
const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void
|
||||
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
|
||||
}
|
||||
return {
|
||||
actions: actions as BakedActions<T, A>,
|
||||
getSnapshot: () => store.getSnapshot(),
|
||||
subscribe: fn => store.subscribe(fn),
|
||||
store,
|
||||
clearPersisted: () => {
|
||||
if (persistKey === undefined || typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(persistKey)
|
||||
} catch {
|
||||
// Storage failures (private mode, quota teardown races) only skip
|
||||
// cleanup — the same non-fatal contract as attachPersistence.
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
5
packages/client/runtime/src/env.d.ts
vendored
Normal file
5
packages/client/runtime/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers
|
||||
* never evaluate a bare `process`. tsconfig carries no node types on purpose.
|
||||
*/
|
||||
declare const process: { env: { NODE_ENV?: string } }
|
||||
@@ -155,13 +155,15 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, useSession} pair; unknown ids yield undefined', async () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
expect(cell?.useSession).toBe(b.svc.manager.get(sid('s1')).useSelector)
|
||||
// Bare-source form (store migration): the cell carries the Session
|
||||
// observable itself; hook binding happens in the React machinery.
|
||||
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -49,9 +49,10 @@ async function boot(): Promise<Bench> {
|
||||
return { ctx, svc, erased: svc as unknown as ErasedService }
|
||||
}
|
||||
|
||||
/** Engine-shaped instance stub (the arbitrated persist face: scope-keyed create + clearPersisted). */
|
||||
/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
|
||||
interface FakeInstance {
|
||||
useSelector: () => undefined
|
||||
getSnapshot: () => undefined
|
||||
subscribe: () => () => void
|
||||
actions: Record<string, never>
|
||||
clearPersisted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
@@ -61,7 +62,10 @@ function fakeHandle() {
|
||||
const created: FakeInstance[] = []
|
||||
const handle = {
|
||||
create: vi.fn((_scopeKey?: string): FakeInstance => {
|
||||
const instance: FakeInstance = { useSelector: () => undefined, actions: {}, clearPersisted: vi.fn() }
|
||||
const instance: FakeInstance = {
|
||||
getSnapshot: () => undefined, subscribe: () => () => undefined,
|
||||
actions: {}, clearPersisted: vi.fn(),
|
||||
}
|
||||
created.push(instance)
|
||||
return instance
|
||||
}),
|
||||
@@ -91,7 +95,9 @@ function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
cell: (id: string) => (id === 'known' ? { sessionId: id, useSession: () => undefined } : undefined),
|
||||
cell: (id: string) => (id === 'known'
|
||||
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
|
||||
: undefined),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
227
packages/client/runtime/tests/store.spec.ts
Normal file
227
packages/client/runtime/tests/store.spec.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/store/index.ts'
|
||||
|
||||
interface State {
|
||||
a: { n: number }
|
||||
b: { list: string[] }
|
||||
}
|
||||
|
||||
const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createSnapshotStore', () => {
|
||||
it('applies update through a draft and preserves untouched branch references', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const before = store.getSnapshot()
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
const after = store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.a.n).toBe(2)
|
||||
expect(after.b).toBe(before.b)
|
||||
})
|
||||
|
||||
it('notifies synchronously per update by default', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const seen: number[] = []
|
||||
store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(seen).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('coalesces a frame of updates into one notification in raf mode', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
store.update((d) => { d.b.list.push('y') })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(store.getSnapshot().a.n).toBe(3)
|
||||
// Next frame batches independently.
|
||||
store.update((d) => { d.a.n = 4 })
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unsubscribes raf-mode listeners', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
const off = store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
off()
|
||||
frame.shift()!(0)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces state wholesale via set and freezes it outside production', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const next = init()
|
||||
store.set(next)
|
||||
expect(store.getSnapshot()).toBe(next)
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('freezes update produce output outside production (immer dev freeze)', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('rehydrates primitive state whole, not spread into index keys', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
store.set('hello')
|
||||
const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
expect(revived.getSnapshot()).toBe('hello')
|
||||
})
|
||||
|
||||
it('persists to localStorage under the given name and rehydrates', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
store.update((d) => { d.a.n = 42 })
|
||||
expect(backing.has('spec-store')).toBe(true)
|
||||
const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
expect(revived.getSnapshot().a.n).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineStore', () => {
|
||||
const declare = () => defineStore({
|
||||
init: () => ({ selection: null as string | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, target: string) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
|
||||
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
|
||||
const inst = declare().create()
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
|
||||
inst.actions.setDraft('hello')
|
||||
inst.actions.select('m1')
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
|
||||
inst.actions.clearDraft()
|
||||
expect(inst.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
|
||||
const inst = declare().create()
|
||||
const before = inst.store.getSnapshot()
|
||||
inst.actions.setDraft('x')
|
||||
const after = inst.store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
|
||||
})
|
||||
|
||||
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
|
||||
const handle = declare()
|
||||
const a = handle.create()
|
||||
const b = handle.create()
|
||||
a.actions.setDraft('only-a')
|
||||
expect(b.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const handle = defineStore({
|
||||
init: () => ({ draft: '' }),
|
||||
persist: 'spec.chat',
|
||||
actions: { setDraft: (d, text: string) => { d.draft = text } },
|
||||
})
|
||||
handle.create('s1').actions.setDraft('one')
|
||||
handle.create('s2').actions.setDraft('two')
|
||||
handle.create().actions.setDraft('root')
|
||||
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
|
||||
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
|
||||
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
|
||||
// Rehydration honors the same suffixed key.
|
||||
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
|
||||
// Scope-death cleanup removes exactly the suffixed key.
|
||||
handle.create('s1').clearPersisted()
|
||||
expect(backing.has('spec.chat.s1')).toBe(false)
|
||||
expect(backing.has('spec.chat.s2')).toBe(true)
|
||||
expect(backing.has('spec.chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
|
||||
const inst = declare().create('s1') // no persist key declared
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
const persisting = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.nostorage',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
// jsdom-less lane: localStorage may exist here, so simulate its absence.
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(() => { persisting.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => { throw new Error('quota / private mode') },
|
||||
})
|
||||
const inst = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.throwing',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowEqual', () => {
|
||||
it('matches one-level-equal objects and rejects deeper drift', () => {
|
||||
const leaf = { deep: 1 }
|
||||
expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
|
||||
expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
|
||||
expect(shallowEqual([1, 2], [1, 2])).toBe(true)
|
||||
expect(shallowEqual([1, 2], [2, 1])).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user