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:
@@ -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