mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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 {
|
||||
|
||||
5
packages/client/web-react/src/env.d.ts
vendored
5
packages/client/web-react/src/env.d.ts
vendored
@@ -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 } }
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user