mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user