feat(gui): chain slot kind with select routing and renderSlotChain

This commit is contained in:
imccyu
2026-07-23 18:03:44 +08:00
parent 0c8b3813fa
commit 3826b50b4a
8 changed files with 400 additions and 36 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type {
HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
+65 -8
View File
@@ -5,9 +5,12 @@
* 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
* (useStore + actions) for store-declaring entries, the renderSlot binding
* (entry-identity bound, stale-checked) for children-declaring entries, and
* the renderSlotChain binding for entries declaring a chain-kind child
* (selector-routed: first non-null select elects and its value joins the
* props as `matched`; all-null falls to the owner fallback).
* 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).
@@ -15,8 +18,8 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionCell, type SlotRenderer, type SlotRendererHost,
type StoredEntry,
type ChainRenderOpts, type RenderOpts, type SessionCell, type SlotRenderer,
type SlotRendererHost, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionProvider, SlotAssemblyError, observableHook, useHost, useSessionCell,
@@ -27,6 +30,9 @@ type InjectedProps = Record<string, unknown>
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */
type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
/**
* Per-entry renderSlot bindings. The binding is identity-stable per entry
* (memoized components must not resubscribe on unrelated re-renders) and dies
@@ -43,9 +49,13 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot
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) {
const declared = entry.children?.[key]
if (declared === undefined) {
throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
}
if (declared.kind === 'chain') {
throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
}
renderSlotCache.set(entry, binding)
@@ -53,6 +63,35 @@ function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlot
return binding
}
/**
* Per-entry renderSlotChain bindings: identity-stable per entry (same cache
* axis as renderSlot — a per-frame dispatch must not rebuild the binding) and
* dead with the entry. The chain-kind check is the plain-JS backstop twin of
* the declaration check; typed callers are narrowed to chain keys.
*/
const renderSlotChainCache = new WeakMap<StoredEntry, RenderSlotChainBinding>()
function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): RenderSlotChainBinding {
let binding = renderSlotChainCache.get(entry)
if (!binding) {
binding = (key, owner, opts) => {
if (!host.isLive(entry)) {
throw new StaleAuthorizationError(`renderSlotChain('${key}') from a disposed registration`)
}
const declared = entry.children?.[key]
if (declared === undefined) {
throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`)
}
if (declared.kind !== 'chain') {
throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`)
}
return <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
}
renderSlotChainCache.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
@@ -144,6 +183,11 @@ function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCe
}
if (entry.children !== undefined) {
kit['renderSlot'] = boundRenderSlot(host, entry)
// renderSlotChain rides the same declaration source: only entries whose
// children include a chain-kind slot receive the chain dispatch seat.
if (Object.values(entry.children).some((spec) => spec.kind === 'chain')) {
kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
}
// SessionProvider standard seat: entries declaring a session-scope child
// render the session area, so the framework hands them the self-wired
// provider (module-level component = stable reference; no value import).
@@ -198,9 +242,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
// 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: StoredEntry, key?: string | number) => (
const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => (
<SlotErrorBoundary slotKey={slotKey} key={key}>
<Entry entry={entry} ownerProps={ownerProps} />
<Entry entry={entry} ownerProps={owner} />
</SlotErrorBoundary>
)
@@ -214,6 +258,19 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
if (!entry) return <>{opts?.fallback ?? null}</>
return guarded(entry)
}
if (spec.kind === 'chain') {
// Entries arrive priority-sorted from the ledger (the core orders at
// register, ties keep registration sequence). Selectors are pure
// functions of the owner props (register-face contract), so the routing
// pass runs per render with zero mount side effects: the first non-null
// election renders, decliners never mount.
for (const entry of entries) {
// Chain entries always carry select (SlotCore register validation).
const matched = (entry.select as (owner: object) => unknown)(ownerProps)
if (matched !== null) return guarded(entry, undefined, { ...ownerProps, matched })
}
return <>{opts?.fallback ?? null}</>
}
// list: registration order refined by explicit order, optional id filter.
const withListOptions = entries.map((entry) => ({
entry,