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

This commit is contained in:
imccyu
2026-07-23 17:19:59 +08:00
parent 0c8b3813fa
commit 3826b50b4a
8 changed files with 400 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-web-react
Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
Shell-side React glue for the slot terminal design: createSlotRenderer (the SlotRenderer implementation the shell installs into the runtime SlotsService), SessionProvider (framework-wired render prop, also injected as a standard seat to entries declaring session-scope children), bindSnapshotSelector (the one hook constructor — hosts and engines traffic in bare observable sources; every hook binds here, cached per source), useInvoke. Chain-slot outlets run the registered selectors in chain order at render time and mount only the elected entry, its select return joining the props as `matched`; the `renderSlotChain` binding is per-entry cached like `renderSlot`. The snapshot-store engine and defineStore live in runtime (store relocation); business plugins depend on ui-slots types only, never on this package.
## Model Experience

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'

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,

View File

@@ -13,13 +13,14 @@ import { act, render } from '@testing-library/react'
import type { ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider, SlotOwnershipError,
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionCell,
type SlotRendererHost, type StoreInstanceLike,
} from '@deepseek-ai/dsh-client-web-react'
type AnyProps = Record<string, unknown>
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode }) => ReactNode
type DeclaredSpec = SlotSpec<SlotEntryDef>
/** Entry literal helper: fake entries default the mandatory options bag. */
const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
@@ -129,7 +130,13 @@ function makeHost() {
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
const entry = entryOf(partial)
entries.set(key, [...(entries.get(key) ?? []), entry])
const next = [...(entries.get(key) ?? []), entry]
// Mirror the ledger contract: chain entries arrive priority-sorted
// (stable, ascending) — outlets iterate entries() order as-is.
if (specs.get(key)?.kind === 'chain') {
next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
}
entries.set(key, next)
live.add(entry)
bump(key)
return () => {
@@ -165,6 +172,29 @@ function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (rende
const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' }
const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' }
const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' }
/** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */
const chainEntryOf = (partial: {
component: unknown
select: (owner: object) => unknown
priority?: number
}): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
component: partial.component,
select: partial.select as StoredEntry['select'],
...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
})
/** Mount a root entry whose component renders `body` with its kit renderSlotChain. */
function mountChainRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) {
const dispose = h.add('root', {
component: (props: { renderSlotChain: RenderSlotChainFn }) => <>{body(props.renderSlotChain)}</>,
children,
})
const renderer = createSlotRenderer()
const view = render(<>{renderer.renderRoot(h.host, {})}</>)
return { view, dispose }
}
describe('root outlet', () => {
it('renders the root registration and fails loud when root is unregistered (boot order)', () => {
@@ -262,6 +292,134 @@ describe('child outlets and the renderSlot binding', () => {
})
})
describe('chain outlets and the renderSlotChain binding', () => {
it('elects the first non-null selector in order, injects matched, and skips decliners without mounting them', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
const declinerBody = vi.fn(() => <span>never</span>)
h.add('k.chain', chainEntryOf({
component: declinerBody,
select: () => null,
}))
h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
select: (owner) => ({ label: `hit:${(owner as { tag: string }).tag}` }),
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', { tag: 'T' }))
// The declining entry never mounts: the routing decision is select-layer only.
expect(view.container.textContent).toBe('hit:T')
expect(declinerBody).not.toHaveBeenCalled()
})
it('falls to the owner fallback when every selector declines, and re-routes live', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
h.add('k.chain', chainEntryOf({
component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
select: (owner) => (owner as { pick?: string }).pick ?? null,
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <>
<main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
<aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
</>)
// Same chain, two dispatch sites: all-null owner props fall back, matching ones elect.
expect(view.container.querySelector('main')!.textContent).toBe('bar')
expect(view.container.querySelector('aside')!.textContent).toBe('P')
})
it('renders the fallback for an empty chain and elects live once an entry registers', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
expect(view.container.textContent).toBe('none')
let dispose = () => {}
act(() => {
dispose = h.add('k.chain', chainEntryOf({
component: () => <b>IN</b>,
select: () => ({}),
}))
})
expect(view.container.textContent).toBe('IN')
act(() => { dispose() })
expect(view.container.textContent).toBe('none')
})
it('orders the chain by ascending priority with registration sequence breaking ties', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
// Registered first but priority 2: must yield to the later priority-1 entry.
h.add('k.chain', chainEntryOf({
component: () => <b>late</b>,
select: () => ({}),
priority: 2,
}))
h.add('k.chain', chainEntryOf({
component: () => <b>early</b>,
select: () => ({}),
priority: 1,
}))
// Tie pair at priority 1: registration order decides (early wins over tie).
h.add('k.chain', chainEntryOf({
component: () => <b>tie</b>,
select: () => ({}),
priority: 1,
}))
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
(renderSlotChain) => renderSlotChain('k.chain', {}))
expect(view.container.textContent).toBe('early')
})
it('keeps the renderSlotChain binding identity-stable across re-renders', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
const seen: RenderSlotChainFn[] = []
mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => {
seen.push(renderSlotChain)
return renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })
})
act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the entry
expect(seen.length).toBeGreaterThan(1)
expect(seen.at(-1)).toBe(seen[0])
})
it('backstops off-declaration keys, kind mismatches both ways, and disposed registrations', () => {
const h = makeHost()
h.declare('k.chain', CHAIN_ROOT)
h.declare('k.single', SINGLE_ROOT)
let chainFn: RenderSlotChainFn | undefined
let slotFn: RenderSlotFn | undefined
const dispose = h.add('root', {
component: (props: { renderSlot: RenderSlotFn; renderSlotChain: RenderSlotChainFn }) => {
slotFn = props.renderSlot
chainFn = props.renderSlotChain
return null
},
children: { 'k.chain': CHAIN_ROOT, 'k.single': SINGLE_ROOT },
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(() => chainFn!('k.undeclared', {})).toThrow(SlotOwnershipError)
expect(() => chainFn!('k.single', {})).toThrow(SlotOwnershipError) // non-chain key via chain face
expect(() => slotFn!('k.chain', {})).toThrow(SlotOwnershipError) // chain key via plain face
view.unmount()
dispose()
expect(() => chainFn!('k.chain', {})).toThrow(StaleAuthorizationError)
})
it('withholds the renderSlotChain seat from entries declaring no chain child', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const seen: AnyProps[] = []
h.add('root', {
component: (props: AnyProps) => { seen.push(props); return null },
children: { 'k.single': SINGLE_ROOT },
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(seen.at(-1)!['renderSlotChain']).toBeUndefined()
})
})
describe('standard-kit synthesis', () => {
it('delivers a live useSessions hook to every slot component', () => {
const h = makeHost()