refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -1,14 +1,22 @@
/**
* ctx-to-React glue: uSES bridge, SessionProvider (dependency-inverted),
* scopedSlots outlet factory, useInvoke. Contract: api-contracts v3 section 2.
* ctx-to-React machinery (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.
*/
import type { ReactNode } from 'react'
import type { SnapshotSelectorHook } from './store/index.ts'
// -- 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 { createSnapshotStore, shallowEqual } from './store/index.ts'
export { defineStore, shallowEqual } from './store/index.ts'
export { bindSnapshotSelector } from './bind.ts'
/**
@@ -19,23 +27,15 @@ export { bindSnapshotSelector } from './bind.ts'
*/
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
/** Session assembly handle narrowed from ui-slots' structural form. */
export interface SessionBinding<Snap extends object = object> {
readonly sessionId: string
readonly session: { useSelector: UseSession<Snap> }
readonly ctx: unknown
}
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
HostObservable, RenderOpts, SessionCell,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
export { createSlotRenderer } from './scoped-slots.tsx'
/** SessionProvider dependency surface (inverted: web-react never imports runtime). */
export interface SessionProviderDeps {
useCurrent: () => string | undefined
resolveBinding: (id: string) => SessionBinding | undefined
/** Assembler-owned body: the shell closes over its own scopedSlots to render the session slots. */
renderBody: (id: string) => ReactNode
}
export { createSessionProvider, RootBindingProvider, SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
export { scopedSlots } from './scoped-slots.tsx'
// -- session area: the framework-wired provider; binding contexts stay internal --
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
export { useInvoke } from './use-invoke.ts'

View File

@@ -1,62 +1,97 @@
/**
* ScopedSlots factory: the sole render surface over the slot registry.
* renderSlot subscribes through uSES (SlotCore.subscribe/getVersion), renders
* per slot kind, wraps every entry in an error boundary, and merges props from
* three sources: standard injection (session slots get useSession), the
* registrant's cached inject factory, then owner props (owner wins).
*
* Typing model (slot type-chain design §4): the key stays generic (`K`) from
* renderSlot down to the outlet, so `entries<K>()` returns typed entries and
* the per-entry render path is monomorphic — no existential casts in loops.
* createSlotRenderer(): the outlet machinery behind the runtime install seam
* (slot terminal design §8). renderRoot mounts the host channel and renders
* the built-in 'root' key; every deeper slot renders through a per-entry
* renderSlot binding synthesized from the entry's children declaration.
* Standard-kit synthesis per entry: the global useSessions hook, the session
* pair (useSession + sessionId) under SessionProvider, the store pair
* (useStore + actions) for store-declaring entries, and the renderSlot
* binding (entry-identity bound, stale-checked) for children-declaring
* entries. Inject factories run inside the entry component bodies ON PURPOSE
* — the per-entry error boundary contains a throwing factory to its own
* entry; parameters follow the declaration (sessionId for session slots,
* baked actions when a store is declared).
*/
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import type {
RenderOpts, RootBinding, ScopedSlots, SessionBinding as SlotSessionBinding,
SlotCore, SlotEntry, SlotMap,
import {
SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionCell, type SlotRenderer, type SlotRendererHost,
type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
import {
HostContext, SlotAssemblyError, observableHook, useHost, useSessionCell,
} from './session-provider.tsx'
type AnyKey = keyof SlotMap & string
type EntryOf<K extends AnyKey> = SlotEntry<SlotMap[K]>
type InjectedProps = Record<string, unknown>
/**
* Inject results cache: root slots per entry, session slots per (entry x binding).
* WeakMap keys are the entry objects (stable across entries() snapshots per
* the SlotCore contract); values are the registrant's injected share. Storage
* erases the per-entry `I` — the single budgeted cast per cache restores it.
*/
const rootInjectCache = new WeakMap<object, InjectedProps>()
const sessionInjectCache = new WeakMap<object, WeakMap<object, InjectedProps>>()
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
function cachedRootInject<K extends AnyKey>(entry: EntryOf<K>, binding: RootBinding): InjectedProps {
const inject = entry.options?.inject
/**
* Per-entry renderSlot bindings. The binding is identity-stable per entry
* (memoized components must not resubscribe on unrelated re-renders) and dies
* with the entry: a retained closure calling after the entry's disposal hits
* the in-ledger check and throws.
*/
const renderSlotCache = new WeakMap<StoredEntry, RenderSlotBinding>()
function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlotBinding {
let binding = renderSlotCache.get(entry)
if (!binding) {
binding = (key, owner, opts) => {
if (!host.isLive(entry)) {
throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`)
}
// Plain-JS backstop; typed callers are narrowed to the declared keys.
if (entry.children?.[key] === undefined) {
throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
}
renderSlotCache.set(entry, binding)
}
return binding
}
/**
* Inject results cache: root entries per entry, session entries per
* (entry x session cell). WeakMap keys are entry/cell objects (both
* identity-stable per registration/session scope), so cache lifetime rides
* the same axes as the values it memoizes.
*/
const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionCell, InjectedProps>>()
function runInject(entry: StoredEntry, cell: SessionCell | undefined, actions: object | undefined): InjectedProps {
const inject = entry.inject
if (!inject) return {}
// Declaration-derived positional arguments: sessionId for session scope,
// baked actions when a store is declared.
const args: unknown[] = []
if (cell !== undefined) args.push(cell.sessionId)
if (actions !== undefined) args.push(actions)
return (inject as (...args: unknown[]) => InjectedProps)(...args)
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
let props = rootInjectCache.get(entry)
if (!props) {
// Root-scope factories accept RootBinding; the conditional-type parameter
// only fails to dispatch because K is generic here — the outlet's
// spec.scope branch guarantees the scope side (budgeted cast, one per cache).
props = (inject as (b: RootBinding) => InjectedProps)(binding)
props = runInject(entry, undefined, actions)
rootInjectCache.set(entry, props)
}
return props
}
function cachedSessionInject<K extends AnyKey>(entry: EntryOf<K>, binding: SlotSessionBinding): InjectedProps {
const inject = entry.options?.inject
if (!inject) return {}
let perBinding = sessionInjectCache.get(entry)
if (!perBinding) {
perBinding = new WeakMap()
sessionInjectCache.set(entry, perBinding)
function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: object | undefined): InjectedProps {
let perCell = sessionInjectCache.get(entry)
if (!perCell) {
perCell = new WeakMap()
sessionInjectCache.set(entry, perCell)
}
let props = perBinding.get(binding)
let props = perCell.get(cell)
if (!props) {
// Same scope-dispatch note as the root cache: the session branch of the
// outlet guarantees this factory's binding side (budgeted cast).
props = (inject as (b: SlotSessionBinding) => InjectedProps)(binding)
perBinding.set(binding, props)
props = runInject(entry, cell, actions)
perCell.set(cell, props)
}
return props
}
@@ -83,68 +118,77 @@ class SlotErrorBoundary extends Component<
}
}
interface OutletProps<K extends AnyKey> {
core: SlotCore
slotKey: K
ownerProps: object
opts?: RenderOpts | undefined
/**
* 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.
*/
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['sessionId'] = cell.sessionId
}
const store = host.storeOf(entry, cell?.sessionId)
if (store !== undefined) {
kit['useStore'] = store.useSelector
kit['actions'] = store.actions
}
if (entry.children !== undefined) {
kit['renderSlot'] = boundRenderSlot(host, entry)
}
return { kit, actions: store?.actions }
}
/**
* One rendered entry: standard injection + cached inject + owner props.
* Inject factories run inside these component bodies ON PURPOSE — the outlet
* wraps every Entry element in the per-entry error boundary, so a throwing
* factory blacks out only its own entry. The three-source merge composes the
* entry's full props contract; TS cannot prove the composition against
* `SlotMap[K]['props']` (the shares are erased at the registry boundary), so
* each Entry renders through a props-widened view of the component — the
* design-budgeted composition point, one per scope branch.
* One rendered entry: standard kit + cached inject + owner props (owner
* wins). The kit and injected shares are erased at the render boundary — the
* register seam already proved the composed contract — so each Entry renders
* through a props-widened view of the component (the design-budgeted
* composition point, one per scope branch).
*/
function SessionEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
}) {
const binding = useSessionBinding()
function SessionEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const cell = useSessionCell()
const Comp = entry.component as FC<InjectedProps>
const injected = cachedSessionInject(entry, binding)
return <Comp useSession={binding.session.useSelector} {...injected} {...ownerProps} />
const { kit, actions } = standardKit(host, entry, cell)
const injected = cachedSessionInject(entry, cell, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
function RootEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
}) {
const hasInject = entry.options?.inject !== undefined
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>
// Only inject-bearing entries need the root binding channel; plain entries
// must render fine in shells that never mounted RootBindingProvider.
if (!hasInject) return <Comp {...ownerProps} />
return <RootInjectEntry entry={entry} ownerProps={ownerProps} />
const { kit, actions } = standardKit(host, entry, undefined)
const injected = cachedRootInject(entry, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
function RootInjectEntry<K extends AnyKey>({ entry, ownerProps }: {
entry: EntryOf<K>; ownerProps: object
function SlotOutlet({ slotKey, ownerProps, opts }: {
slotKey: string; ownerProps: object; opts?: RenderOpts | undefined
}) {
const binding = useRootBinding()
const Comp = entry.component as FC<InjectedProps>
const injected = cachedRootInject(entry, binding)
return <Comp {...injected} {...ownerProps} />
}
function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: OutletProps<K>) {
// Version tick drives entries() re-read; SlotCore batches per microtask.
const host = useHost()
// Version tick drives entries() re-read; the host batches per microtask.
useSyncExternalStore(
(fn) => core.subscribe(slotKey, fn),
() => core.getVersion(slotKey),
(fn) => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
const spec = core.spec(slotKey)
if (!spec) throw new Error(`renderSlot('${slotKey}') before define`)
const entries = core.entries(slotKey)
const Entry: FC<{ entry: EntryOf<K>; ownerProps: object }> =
spec.scope === 'session' ? SessionEntry : RootEntry
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
// unload returns the slot to the undeclared state while retained elements
// may still be mounted — natural empty, not an ownership failure (§9).
if (!spec) return null
const entries = host.entriesOf(slotKey)
const Entry = spec.scope === 'session' ? SessionEntry : RootEntry
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
// factories and binding lookups run in the Entry body and must land in the
// factories and kit synthesis run in the Entry body and must land in the
// per-entry fallback rather than escaping to the tree above.
const guarded = (entry: EntryOf<K>, key?: string | number) => (
const guarded = (entry: StoredEntry, key?: string | number) => (
<SlotErrorBoundary slotKey={slotKey} key={key}>
<Entry entry={entry} ownerProps={ownerProps} />
</SlotErrorBoundary>
@@ -156,15 +200,15 @@ function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: Outle
return guarded(entry)
}
if (spec.kind === 'keyed') {
const entry = entries.find((e) => e.options && 'key' in e.options && e.options.key === opts?.entryKey)
const entry = entries.find((e) => e.options?.key === opts?.entryKey)
if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry)
}
// list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map((entry) => ({
entry,
id: entry.options && 'id' in entry.options ? entry.options.id : undefined,
order: entry.options && 'order' in entry.options ? entry.options.order ?? 0 : 0,
id: entry.options?.id,
order: entry.options?.order ?? 0,
}))
let list = [...withListOptions].sort((a, b) => a.order - b.order)
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
@@ -172,20 +216,36 @@ function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: Outle
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
}
/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */
function RootOutlet({ ownerProps }: { ownerProps: object }) {
const host = useHost()
useSyncExternalStore(
(fn) => host.subscribe('root', fn),
() => host.getVersion('root'),
)
const entry = host.entriesOf('root')[0]
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
return (
<SlotErrorBoundary slotKey="root">
<RootEntry entry={entry} ownerProps={ownerProps} />
</SlotErrorBoundary>
)
}
/**
* Build a whitelist-narrowed ScopedSlots render surface over a SlotCore.
* The type parameter narrows compile-time access; the runtime whitelist
* backstops plain-JS callers.
* @param core - the slot registry core.
* @param keys - whitelisted slot keys the caller may render.
* @returns the ScopedSlots facade.
* Build the renderer the shell installs into the runtime SlotsService
* (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
* install/renderSlot seam and the double-install/not-installed throws).
* @returns the renderer.
*/
export function scopedSlots<K extends AnyKey>(core: SlotCore, ...keys: K[]): ScopedSlots<K> {
const allowed = new Set<string>(keys)
export function createSlotRenderer(): SlotRenderer {
return {
renderSlot(key, props, opts) {
if (!allowed.has(key)) throw new Error(`slot '${key}' is not in this ScopedSlots whitelist`)
return <SlotOutlet core={core} slotKey={key} ownerProps={props} opts={opts} />
renderRoot(host, ownerProps) {
return (
<HostContext.Provider value={host}>
<RootOutlet ownerProps={ownerProps} />
</HostContext.Provider>
)
},
}
}

View File

@@ -1,15 +1,15 @@
/**
* SessionProvider (dependency-inverted; never imports runtime) plus the two
* binding contexts the slot outlet reads: per-session {@link BindingContext}
* written here, and the root-binding channel written by the shell through
* {@link RootBindingProvider}.
* SessionProvider (framework-wired render prop, slot terminal design §7) plus
* the two internal channels the render machinery shares: the renderer host
* context (written once by createSlotRenderer's root) and the per-session
* binding context (written here, read by session-scope outlets). Both
* contexts are in-package machinery — they are NOT exported from the package
* index; business components see zero React contexts.
*/
import { createContext, useContext, type FC, type ReactNode } from 'react'
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionBinding, SessionProviderDeps } from './index.ts'
/** Session binding for the subtree under SessionProvider (module-private write). */
const BindingContext = createContext<SessionBinding | null>(null)
import { createContext, useContext, type ReactNode } from 'react'
import type { HostObservable, SessionCell, SlotRendererHost } 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
@@ -19,56 +19,75 @@ const BindingContext = createContext<SessionBinding | null>(null)
*/
export class SlotAssemblyError extends Error {}
/** Renderer host channel: written by createSlotRenderer's root element (in-package machinery only). */
export const HostContext = createContext<SlotRendererHost | null>(null)
/**
* Read the enclosing session binding; throws outside a SessionProvider
* subtree (session slots must not render without a session).
* @returns the enclosing binding.
* Read the installed renderer host; throws outside the rendered root tree
* (framework components must not render detached from the renderer).
* @returns the host surface.
*/
export function useSessionBinding(): SessionBinding {
const binding = useContext(BindingContext)
if (!binding) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
return binding
export function useHost(): SlotRendererHost {
const host = useContext(HostContext)
if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
return host
}
const RootBindingContext = createContext<RootBinding | null>(null)
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
const BindingContext = createContext<SessionCell | null>(null)
/**
* Root-binding supply channel: the shell mounts this once at the top so root
* slot inject factories receive their assembly handle.
* Read the enclosing session cell; throws outside a SessionProvider subtree
* (session slots must not render without a session).
* @returns the enclosing cell.
*/
export const RootBindingProvider: FC<{ value: RootBinding; children?: ReactNode }> =
({ value, children }) => (
<RootBindingContext.Provider value={value}>{children}</RootBindingContext.Provider>
)
/**
* Read the root binding; throws when the shell forgot to mount
* {@link RootBindingProvider} (root inject factories need ctx).
* @returns the root binding.
*/
export function useRootBinding(): RootBinding {
const binding = useContext(RootBindingContext)
if (!binding) throw new SlotAssemblyError('root slot inject requires RootBindingProvider above')
return binding
export function useSessionCell(): SessionCell {
const cell = useContext(BindingContext)
if (!cell) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
return cell
}
/**
* Build the single SessionProvider component: subscribes to the current
* session id, resolves its binding (stable reference), remounts the body
* under key={id}, and delegates body rendering to the assembler's renderBody
* (slot ownership stays with layout; the provider knows no slot names).
* @param deps - inverted dependencies.
* @returns the provider component.
* Identity-stable selector hook per host observable. uSES resubscribes when
* the subscribe reference changes, so the bound hook must be created once per
* source — cached here by source identity (sources are host-owned singletons).
* @param source - host-provided observable.
* @returns the cached selector hook.
*/
export function createSessionProvider(deps: SessionProviderDeps): FC<{ renderEmpty?: () => ReactNode }> {
return function SessionProvider({ renderEmpty }) {
const id = deps.useCurrent()
const binding = id === undefined ? undefined : deps.resolveBinding(id)
if (id === undefined || !binding) return <>{renderEmpty?.() ?? null}</>
return (
<BindingContext.Provider value={binding} key={id}>
{deps.renderBody(id)}
</BindingContext.Provider>
)
export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
let hook = hookCache.get(source)
if (hook === undefined) {
hook = bindSnapshotSelector(source)
hookCache.set(source, hook)
}
return hook as SnapshotSelectorHook<T>
}
const hookCache = new WeakMap<object, unknown>()
/** SessionProvider surface: render-prop body plus the no-session branch. */
export interface SessionProviderProps {
/** No-session body (also covers a current id whose session cannot be resolved). */
empty?: (() => ReactNode) | undefined
/** Session body; remounted per session via key={sessionId}. */
children: (sessionId: string) => ReactNode
}
/**
* Framework-wired session area: subscribes to the host's current-session
* source (design fiat ① — selection authority lives with runtime sessions),
* resolves the session cell, and remounts the body under key={sessionId} so
* a session switch rebuilds the whole session subtree. Ids speak plain
* string at this dependency-inverted layer; branding lands on the component
* props seam (PropsRuntime).
*/
export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost()
const id = observableHook(host.sessions.current)((s) => s)
const cell = id === undefined ? undefined : host.sessions.cell(id)
if (id === undefined || cell === undefined) return <>{empty?.() ?? null}</>
return (
<BindingContext.Provider value={cell} key={id}>
{children(id)}
</BindingContext.Provider>
)
}

View File

@@ -1,14 +1,27 @@
/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze). The only data contract
* consumed by React is {@link ObservableSnapshot}.
* 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 }
@@ -152,3 +165,86 @@ function deepFreeze(value: unknown): void {
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.
}
},
}
},
}
}