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
+161 -101
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>
)
},
}
}