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:
imccyu
2026-07-23 02:56:16 +08:00
parent 004a988168
commit 8b3d1ac943
77 changed files with 484 additions and 317 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-web-react
ctx↔React machinery for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop over the host's current-session source), defineStore (the declarative store shell over the internal zustand engine), bindSnapshotSelector, useInvoke. The snapshot-store engine (createSnapshotStore) is framework-internal via the `./store` subpath; business plugins declare stores through defineStore only.
Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
## Model Experience

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-web-react",
"description": "ctx-to-React glue: createSnapshotStore (zustand engine), bindSnapshotSelector, SessionProvider, scopedSlots outlet, useInvoke",
"description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,20 +15,14 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./store": {
"types": "./lib/types/store/index.d.ts",
"default": "./lib/store/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"use-sync-external-store": "1.2.0",
"zustand": "~4.4.7"
"use-sync-external-store": "1.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -42,7 +36,6 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/store/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -1,19 +1,21 @@
/**
* uSES bridge: turns any {@link ObservableSnapshot} into a typed selector
* hook. Client-side-rendered only, so no server snapshot is wired.
* uSES bridge: turns any bare observable snapshot source into a typed
* selector hook. Client-side-rendered only, so no server snapshot is wired.
* This is the ONE hook constructor in the client stack — engines and hosts
* traffic in bare sources; binding happens on the React side.
*/
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
import type { ObservableSnapshot, SnapshotSelectorHook } from './store/index.ts'
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
/**
* Bind an observable snapshot source to a typed uSES selector hook.
* Bind a bare observable source to a typed uSES selector hook.
* subscribe/getSnapshot are captured once per source into stable closures
* (also re-binds `this` for method-based sources), so components never
* resubscribe across renders. Equality defaults to Object.is.
* @param w - snapshot source (Session object or snapshot store).
* @param w - snapshot source (engine store, Session object, store instance).
* @returns the selector hook.
*/
export function bindSnapshotSelector<T>(w: ObservableSnapshot<T>): SnapshotSelectorHook<T> {
export function bindSnapshotSelector<T>(w: HostObservable<T>): SnapshotSelectorHook<T> {
const subscribe = (fn: () => void) => w.subscribe(fn)
const getSnapshot = () => w.getSnapshot()
return function useSelector<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S {

View File

@@ -1,5 +0,0 @@
/**
* 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 } }

View File

@@ -1,22 +1,15 @@
/**
* ctx-to-React machinery (slot terminal design §8): createSlotRenderer (the
* Shell-side React glue (slot terminal design §8): createSlotRenderer (the
* install-seam implementation), SessionProvider (framework-wired render
* prop), the defineStore shell, and useInvoke. Contract types (SlotRenderer
* family, store family, four-share props) are ui-slots authority — this face
* re-exports the ones its own values traffic in. The snapshot-store ENGINE
* (createSnapshotStore) is framework-internal — runtime/i18n reach it through
* the './store' subpath; business plugins declare stores via defineStore
* only. React contexts stay in-package: business components see none.
* prop, also delivered as a standard seat to session-area entries),
* bindSnapshotSelector (the one hook constructor), and useInvoke. The
* snapshot-store engine and defineStore live in runtime (store relocation);
* contract types are ui-slots authority — this face re-exports only what its
* own values traffic in. React contexts stay in-package: business components
* see none.
*/
import type { SnapshotSelectorHook } from './store/index.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// -- store: the declarative shell is public; the engine stays off this face --
export type {
ActionsDecl, BakedActions, BoundActions, EngineStoreHandle, EngineStoreInstance,
ObservableSnapshot, SnapshotSelectorHook, SnapshotStore,
StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from './store/index.ts'
export { defineStore, shallowEqual } from './store/index.ts'
export { bindSnapshotSelector } from './bind.ts'
/**
@@ -29,7 +22,7 @@ export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
HostObservable, RenderOpts, SessionCell,
HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'

View File

@@ -19,7 +19,7 @@ import {
type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SlotAssemblyError, observableHook, useHost, useSessionCell,
HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
} from './session-provider.tsx'
type InjectedProps = Record<string, unknown>
@@ -120,26 +120,36 @@ class SlotErrorBoundary extends Component<
/**
* Standard-kit synthesis shared by both scope branches: the global
* useSessions hook, the store pair when declared, and the renderSlot binding
* when children are declared. Every member is identity-stable (hook cache /
* host store cache / binding cache), so spreading a fresh kit object per
* render never churns child subscriptions.
* useSessions hook, the session pair, the store pair when declared, the
* renderSlot binding when children are declared, and the SessionProvider
* seat when the children declare a session-scope slot. Hosts hand out BARE
* observable sources (hooks never cross the host contract); every hook is
* bound HERE, cached per source (observableHook), so spreading a fresh kit
* object per render never churns child subscriptions.
*/
function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
kit: InjectedProps; actions: object | undefined
} {
const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) }
if (cell !== undefined) {
kit['useSession'] = cell.useSession
kit['useSession'] = observableHook(cell.session)
kit['sessionId'] = cell.sessionId
}
const store = host.storeOf(entry, cell?.sessionId)
if (store !== undefined) {
kit['useStore'] = store.useSelector
// The instance IS an observable snapshot source (contract getSnapshot/
// subscribe); the useStore hook binds here, cached per instance.
kit['useStore'] = observableHook(store)
kit['actions'] = store.actions
}
if (entry.children !== undefined) {
kit['renderSlot'] = boundRenderSlot(host, entry)
// SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import).
if (Object.values(entry.children).some((spec) => spec.scope === 'session')) {
kit['SessionProvider'] = SessionProvider
}
}
return { kit, actions: store?.actions }
}

View File

@@ -7,9 +7,10 @@
* index; business components see zero React contexts.
*/
import { createContext, useContext, type ReactNode } from 'react'
import type { HostObservable, SessionCell, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import type {
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from './bind.ts'
import type { SnapshotSelectorHook } from './store/index.ts'
/**
* A missing-provider assembly error: the shell wired the tree wrong. The slot

View File

@@ -1,250 +0,0 @@
/**
* 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). The engine ({@link createSnapshotStore}) stays the
* substrate for framework data (runtime sessions/loader/i18n); business
* plugins declare stores through defineStore only.
*/
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'
import { bindSnapshotSelector } from '../bind.ts'
// Store contract types are ui-slots authority (wave 1); this module re-exports
// them 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 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])
}
}
// ---- 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 {
useSelector: store.useSelector,
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.
}
},
}
},
}
}

View File

@@ -2,8 +2,15 @@
import { StrictMode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { bindSnapshotSelector, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react/store'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Local one-level equality: the engine's shallowEqual moved to runtime with
// the store relocation, and web-react tests must not import runtime (the
// dependency direction is runtime → web-react). The eq PARAMETER contract is
// what this suite asserts, not any specific equality implementation.
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
interface Snap { a: number; b: number }
@@ -34,7 +41,7 @@ function Harness<S>({ useSelector, sel, eq, probe }: {
useSelector: SnapshotSelectorHook<Snap>
sel: (s: Snap) => S
eq?: (a: S, b: S) => boolean
probe: { renders: number; value?: S }
probe: { renders: number; value?: S | undefined }
}) {
probe.renders += 1
probe.value = useSelector(sel, eq)

View File

@@ -11,9 +11,9 @@
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, defineStore, SessionProvider, SlotOwnershipError,
createSlotRenderer, SessionProvider, SlotOwnershipError,
type RenderOpts, type SessionCell,
type SlotRendererHost, type StoreInstanceLike,
} from '@deepseek-ai/dsh-client-web-react'
@@ -25,6 +25,36 @@ type DeclaredSpec = SlotSpec<SlotEntryDef>
const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
({ options: {}, ...partial })
/**
* Minimal store handle satisfying the StoreDecl contract shape (spec +
* create(scopeKey?) + instance with clearPersisted): the machinery consumes
* only the StoreInstanceLike face (bare snapshot source + baked actions),
* but entry.store is typed to the full contract — the real defineStore lives
* in runtime, which web-react tests must not import (dependency direction).
*/
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
return {
spec: { init, actions: {} },
create: () => {
let state = init()
const listeners = new Set<() => void>()
const actions: Record<string, (...params: never[]) => void> = {}
for (const key of Object.keys(mutators)) {
actions[key] = (...params: never[]) => {
state = mutators[key]!(state, ...params)
for (const fn of [...listeners]) fn()
}
}
return {
getSnapshot: () => state,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
actions,
clearPersisted: () => {},
} as StoreInstanceLike as ReturnType<StoreHandle<T, ActionsDecl<T>>['create']>
},
}
}
function observable<T>(initial: T) {
let value = initial
const subs = new Set<() => void>()
@@ -109,7 +139,11 @@ function makeHost() {
}
},
addSession: (id: string): SessionCell => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
// Bare source per cell (identity-stable): the machinery binds useSession from it.
const cell: SessionCell = {
sessionId: id,
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
}
cells.set(id, cell)
return cell
},
@@ -242,12 +276,17 @@ describe('standard-kit synthesis', () => {
expect(view.container.textContent).toBe('2')
})
it('delivers the session pair (useSession identity + sessionId) under SessionProvider', () => {
it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
const cell = h.addSession('s1')
h.addSession('s1')
const seen: AnyProps[] = []
h.add('k.session', { component: (props: object) => { seen.push(props as AnyProps); return null } })
h.add('k.session', {
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
seen.push({ ...props, read: props.useSession!((s) => s.sid) })
return null
},
})
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
<SessionProvider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
@@ -255,10 +294,51 @@ describe('standard-kit synthesis', () => {
))
act(() => { h.current.set('s1') })
const props = seen.at(-1)!
expect(props['useSession']).toBe(cell.useSession)
// The hook is BOUND by the machinery from the cell's bare source: it
// reads the source's snapshot and stays identity-stable across renders
// (per-source cache), which the switch-back cache tests cover.
expect(props['read']).toBe('s1')
expect(props['sessionId']).toBe('s1')
})
it('hands the SessionProvider seat to entries declaring a session-scope child', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
h.declare('k.single', SINGLE_ROOT)
h.addSession('s1')
h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b> })
const rootSeen: AnyProps[] = []
// Root entry uses its INJECTED provider seat (no value import of SessionProvider).
h.add('root', {
component: (props: AnyProps) => {
rootSeen.push(props)
const Provider = props['SessionProvider'] as typeof SessionProvider
const renderSlot = props['renderSlot'] as RenderSlotFn
return (
<Provider empty={() => <i>empty</i>}>
{() => renderSlot('k.session', {})}
</Provider>
)
},
children: { 'k.session': SINGLE_SESSION },
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
// Entries whose children are all root-scope get no provider seat.
const h2 = makeHost()
h2.declare('k.single', SINGLE_ROOT)
const seen2: AnyProps[] = []
h2.add('root', {
component: (props: AnyProps) => { seen2.push(props); return null },
children: { 'k.single': SINGLE_ROOT },
})
render(<>{createSlotRenderer().renderRoot(h2.host, {})}</>)
expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
})
it('fails loud when a session slot renders outside SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
@@ -272,10 +352,7 @@ describe('standard-kit synthesis', () => {
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const handle = defineStore({
init: () => ({ n: 0 }),
actions: { inc: (d) => { d.n += 1 } },
})
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
let bump = () => {}
h.add('k.single', {
component: ({ useStore, actions }: {
@@ -298,10 +375,7 @@ describe('standard-kit synthesis', () => {
h.declare('k.session', SINGLE_SESSION)
h.addSession('s1')
h.addSession('s2')
const handle = defineStore({
init: () => ({ draft: '' }),
actions: { setDraft: (d, text: string) => { d.draft = text } },
})
const handle = miniStore(() => ({ draft: '' }), { setDraft: (_s, text: string) => ({ draft: text }) })
let setDraft: (text: string) => void = () => {}
h.add('k.session', {
component: ({ useStore, actions }: {
@@ -369,7 +443,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
h.declare('k.single', SINGLE_ROOT)
h.declare('k.session', SINGLE_SESSION)
h.addSession('s1')
const handle = defineStore({ init: () => ({ n: 0 }), actions: { inc: (d) => { d.n += 1 } } })
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
const seenRoot: AnyProps[] = []

View File

@@ -57,7 +57,11 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
host,
current,
addSession: (id: string) => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
// Bare source per cell (identity-stable): the machinery binds useSession from it.
const cell: SessionCell = {
sessionId: id,
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
}
cells.set(id, cell)
return cell
},
@@ -121,15 +125,23 @@ describe('SessionProvider', () => {
const h = makeHost({
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const s1 = h.addSession('s1')
const s2 = h.addSession('s2')
h.registerSession({ component: (props: object) => { seen.push(props as Record<string, unknown>); return null }, options: {} })
h.addSession('s1')
h.addSession('s2')
h.registerSession({
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
// The bound hook reads the cell's bare source — asserting through it
// proves the machinery wired THIS session's source, not another's.
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
return null
},
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)!['useSession']).toBe(s1.useSession)
expect(seen.at(-1)!['read']).toBe('s1')
expect(seen.at(-1)!['sessionId']).toBe('s1')
act(() => { h.current.set('s2') })
expect(seen.at(-1)!['useSession']).toBe(s2.useSession)
expect(seen.at(-1)!['read']).toBe('s2')
expect(seen.at(-1)!['sessionId']).toBe('s2')
})

View File

@@ -1,227 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore, defineStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
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)
})
})

View File

@@ -1,12 +1,12 @@
import { defineConfig } from 'tsdown'
/**
* Root shape plus the store subpath, built as SEPARATE single-entry bundles:
* a multi-entry build emits a hash-named shared chunk that the exact `files`
* whitelist cannot publish (same shape as code-runtime-worker/user-approval).
* Each entry inlines the shared store code instead; the node lib is the
* repo-uniform shape (publint/NodeNext), not an identity-sensitive runtime —
* browser consumers resolve this package through the loader module table.
* Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry
* build emits a hash-named shared chunk that the exact `files` whitelist
* cannot publish (same shape as code-runtime-worker/user-approval). The node
* lib is the repo-uniform shape (publint/NodeNext), not an identity-sensitive
* runtime — browser consumers resolve this package through the loader module
* table.
*/
export default defineConfig([
{
@@ -29,14 +29,4 @@ export default defineConfig([
dts: false,
clean: false,
},
{
entry: { 'store/index': 'lib/types/store/index.js' },
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])