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,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>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user