diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index e7adb5a79b..16d6380639 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -11,11 +11,13 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co | store | `PropsStore` | the declared handle: `useStore` selector hook + draft-stripped `actions` | | business | `I` | inferred from the `inject` factory's return | +Chain-kind slots invert keyed routing — entries self-nominate instead of the dispatch site picking an `entryKey`: each registration carries a pure `ChainSelect` selector (plus optional ascending `priority`, ties in registration order), the first non-null return elects its entry and becomes the component's `matched` prop, and all-null falls to the owner's `renderSlotChain` fallback (`ChainRenderOpts`). + The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx. The store family (`defineStore` spec in / `StoreHandle` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here. -`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. +`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. ## Model Experience diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index e08f884f44..809ba21d5a 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -22,8 +22,8 @@ export * from './renderer.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} -/** Slot cardinality: single occupant, ordered list, or key-dispatched. */ -export type SlotKind = 'single' | 'list' | 'keyed' +/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ +export type SlotKind = 'single' | 'list' | 'keyed' | 'chain' /** Slot data context: root (no session) or session-bound. */ export type SlotScope = 'root' | 'session' @@ -98,6 +98,34 @@ export type PropsRuntime = /** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */ export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode } +/** renderSlotChain dispatch options: the owner's fallback body, rendered when every entry's selector declines. */ +export interface ChainRenderOpts { fallback?: ReactNode } + +/** + * Chain-entry selector: the routing decision of one chain contribution. + * Runs at render time in chain order (ascending `priority`, default 0, lower + * tries first; ties keep registration = assembly order); the first non-null + * return elects its entry + * and becomes the component's `matched` prop; `null` passes to the next + * entry; all-null falls to the owner's {@link ChainRenderOpts} fallback. + * MUST be pure — a function of the owner props only, no external mutable + * reads, no side effects (the decline decision lives here, never in a + * mounted component probing its own props). + */ +export type ChainSelect = (owner: O) => M | null + +/** Keys of a slot-key union whose SlotMap entry is chain-kind (renderSlotChain's dispatch domain). */ +export type ChainKeysOf = + S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never + +/** + * Chain matched share: a chain-slot component receives its selector's + * non-null result as the framework-injected `matched` prop; other kinds add + * nothing to the composed constraint. + */ +export type MatchedShare = + E['kind'] extends 'chain' ? { matched: M } : object + /** * Conversation-session selector hook alias for props contracts. Wide by * default at this dependency-inverted layer; the runtime narrows at its @@ -135,15 +163,27 @@ export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode */ export type PropsRenderSlots = { /** - * Render a declared child slot. + * Render a declared non-chain child slot (chain keys dispatch through + * `renderSlotChain` — their routing lives in entry selectors). * @param key - declared child key. * @param owner - owner props share for that key (decided at the render site). * @param opts - kind dispatch options. * @returns rendered node(s). */ - renderSlot: (key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode + renderSlot: >>(key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode readonly __renders?: ((key: S) => void) | undefined -} & ('session' extends ScopeOf +} & ([ChainKeysOf] extends [never] ? object : { + /** + * Render a declared chain child slot: entry selectors run in chain order + * over `owner`; the first non-null match renders its component with the + * selector result injected as `matched`; all-null renders `opts.fallback`. + * @param key - declared chain child key. + * @param owner - owner props share (the selectors' routing input). + * @param opts - fallback body for the all-null case. + * @returns rendered node(s). + */ + renderSlotChain: >(key: K, owner: OwnerOf, opts?: ChainRenderOpts) => ReactNode +}) & ('session' extends ScopeOf // The SessionProvider seat rides the same source as renderSlot: declaring // a session-scope child is what makes a session area exist, so the seat // derives from the children key set's scopes (renderer injects the value). @@ -168,7 +208,8 @@ export type ComposedProps< S extends keyof SlotMap & string, H, I extends object, -> = PropsRuntime & PropsRenderSlots & PropsStore & I + M = never, +> = PropsRuntime & PropsRenderSlots & PropsStore & I & MatchedShare /** * Inject factory parameter list, derived from the registration's declaration: @@ -182,27 +223,35 @@ export type InjectParams = ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions>] : [sessionId: SessionIdOf]) : ([H] extends [StoreDecl] ? [actions: BoundActions>] : []) -/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label). */ -export type KindOptions = +/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ +export type KindOptions = E['kind'] extends 'keyed' ? { key: string } : E['kind'] extends 'list' ? { id: string; order?: number; label?: string } - : object + : E['kind'] extends 'chain' ? { + /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ + select: ChainSelect + /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ + priority?: number + } + : object /** * Compile-time presence check: an entry declaring children MUST consume - * `renderSlot` (declaring is claiming — an entry that does not render its - * children should not declare them). Evaluates to an unsatisfiable - * intersection member naming the declared keys when violated. + * `renderSlot` (or `renderSlotChain` when its only children are chain slots) + * — declaring is claiming; an entry that does not render its children should + * not declare them. Evaluates to an unsatisfiable intersection member naming + * the declared keys when violated. */ type RendersCheck = [keyof D & keyof SlotMap & string] extends [never] ? unknown : C extends (props: infer P) => ReactNode ? ('renderSlot' extends keyof P ? unknown - : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string }) + : 'renderSlotChain' extends keyof P ? unknown + : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string }) : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ @@ -211,7 +260,7 @@ type BaseOptions = store?: H /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ registrant?: string -} & KindOptions +} & KindOptions /** * One stored registration, as recorded by the core and read by the render @@ -220,7 +269,9 @@ type BaseOptions = */ export interface StoredEntry { component: unknown - options: { key?: string; id?: string; order?: number; label?: string } + options: { key?: string; id?: string; order?: number; label?: string; priority?: number } + /** Chain routing selector (type-erased like `inject`; present exactly on chain-slot entries). */ + select?: ((owner: never) => unknown) | undefined /** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */ inject?: ((...args: never[]) => Record) | undefined /** Child-slot declaration table (declaration + authorization + runtime spec in one). */ @@ -243,6 +294,8 @@ interface ErasedOptions { id?: string | undefined order?: number | undefined label?: string | undefined + select?: ((owner: never) => unknown) | undefined + priority?: number | undefined children?: Record> | undefined store?: StoreDecl | undefined /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- @@ -308,7 +361,8 @@ export class SlotCore { * names the first declarer); mounting one shared store handle under slots * of different scopes throws. Kind constraints: single — duplicate * registration throws; keyed — missing/duplicate `key` throws; list — - * missing/duplicate `id` throws. + * missing/duplicate `id` throws; chain — missing `select` throws (the + * selector is the entry's routing seat, see {@link ChainSelect}). * * Lifecycle: the disposer removes the contribution AND collapses every * declared child slot (child entries clear recursively; their stale @@ -326,11 +380,12 @@ export class SlotCore { K extends keyof SlotMap & string, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, + M = never, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object>> + & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer>> & RendersCheck, ): () => void /** @@ -348,11 +403,12 @@ export class SlotCore { I extends object, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, + M = never, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I>> + & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer>> & RendersCheck, ): () => void register(options: ErasedOptions, component: unknown): () => void { @@ -379,6 +435,9 @@ export class SlotCore { throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`) } break + case 'chain': + if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`) + break } if (options.children) { for (const childKey of Object.keys(options.children)) { @@ -407,15 +466,19 @@ export class SlotCore { ...(options.id !== undefined ? { id: options.id } : {}), ...(options.order !== undefined ? { order: options.order } : {}), ...(options.label !== undefined ? { label: options.label } : {}), + ...(options.priority !== undefined ? { priority: options.priority } : {}), }, + ...(options.select !== undefined ? { select: options.select } : {}), ...(options.inject !== undefined ? { inject: options.inject } : {}), ...(options.children !== undefined ? { children: options.children } : {}), ...(options.store !== undefined ? { store: options.store } : {}), ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] - // Stable sort: order ascending, ties keep registration sequence. + // Stable sorts: ascending, ties keep registration sequence (list rides + // `order`, chain rides `priority` — lower priority tries first). if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0)) + if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) rec.entries = next this.markDirty(options.name, rec) if (options.children) { diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.spec.ts index 87c96177d8..8ed6b56498 100644 --- a/packages/client/ui-slots/tests/core.spec.ts +++ b/packages/client/ui-slots/tests/core.spec.ts @@ -13,6 +13,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'test.session': { kind: 'single'; scope: 'session' } 'test.list': { kind: 'list'; scope: 'root' } 'test.keyed': { kind: 'keyed'; scope: 'session' } + 'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } } 'test.grandchild': { kind: 'single'; scope: 'root' } } } @@ -39,6 +40,7 @@ function mountFrame(core: SlotCore) { 'test.session': { kind: 'single', scope: 'session' }, 'test.list': { kind: 'list', scope: 'root' }, 'test.keyed': { kind: 'keyed', scope: 'session' }, + 'test.chain': { kind: 'chain', scope: 'session' }, }, // Type-level renderSlot presence is proven by the type-chain spec; erasing // here keeps runtime fixtures terse. @@ -148,6 +150,31 @@ describe('kind semantics', () => { expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c']) }) + it('chain: missing select throws; select and priority land on the stored entry', () => { + const core = new SlotCore() + mountFrame(core) + // Statically rejected (KindOptions); runtime guard stays for dynamic callers. + // @ts-expect-error chain registration requires options.select + expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select') + const select = ({ tags }: { tags: string[] }) => tags[0] ?? null + core.register({ name: 'test.chain', select, priority: 5 }, Comp as never) + const entry = core.entries('test.chain')[0]! + expect(entry.select).toBe(select) + expect(entry.options.priority).toBe(5) + }) + + it('chain: entries sort by priority ascending, ties keep registration order', () => { + const core = new SlotCore() + mountFrame(core) + const sel = () => null + core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never) + core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never) + core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never) + core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never) + expect(core.entries('test.chain').map(e => e.registrant)) + .toEqual(['first', 'default-a', 'default-b', 'late']) + }) + it('single: second registration throws, disposer frees the seat', () => { const core = new SlotCore() mountFrame(core) @@ -294,7 +321,7 @@ describe('subscription surface', () => { const off = core.onMutate(key => keys.push(key)) mountFrame(core) // Contribution first, then each declared child key. - expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed']) + expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain']) keys.length = 0 core.register({ name: 'test.list', id: 'a' }, Comp) expect(keys).toEqual(['test.list']) diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.spec.tsx index d45dfee0a3..8469697557 100644 --- a/packages/client/ui-slots/tests/type-chain.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.spec.tsx @@ -20,9 +20,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } } 'chain.conv': { kind: 'single'; scope: 'session' } 'chain.tools': { kind: 'keyed'; scope: 'session' } + 'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } } } } +/** Chain-currency fixture: the owner share carries a union the selectors narrow. */ +interface Item { kind: 'q' | 'a'; id: string } + declare const defineStore: DefineStore /** Factory form (exclusive seat): module-level export, never a handle. */ @@ -68,6 +72,9 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore>): ReactNode declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode +declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode +declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode +declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode describe('terminal-design type chain', () => { it('holds the positive chain and the compile-time negatives', () => { @@ -115,6 +122,28 @@ describe('terminal-design type chain', () => { // Keyed registration carries key. core.register({ name: 'chain.tools', key: 'bash' }, Tool) + // Chain registration: select is mandatory, M infers from its return, + // matched joins the component constraint; priority is the explicit + // chain position. + core.register({ + name: 'chain.takeover', + select: ({ items }) => items.find((i) => i.kind === 'q') ?? null, + priority: 1, + }, Takeover) + + // A component accepting a wider matched than the selector supplies + // checks through parameter contravariance. + core.register({ + name: 'chain.takeover', + select: ({ items }) => items.find((i) => i.kind === 'q') ?? null, + }, WideTakeover) + + // renderSlotChain share: chain keys dispatch with the fallback bag; + // non-chain keys stay on renderSlot. + const chainSlots: PropsRenderSlots<'chain.takeover' | 'chain.conv'> = null as never + chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null }) + chainSlots.renderSlot('chain.conv', {}) + // ── negatives ────────────────────────────────────────────────── // children spec must match the SlotMap entry. core.register({ @@ -156,6 +185,34 @@ describe('terminal-design type chain', () => { // @ts-expect-error keyed registration requires options.key core.register({ name: 'chain.tools' }, Tool) + // chain registration without select. + // @ts-expect-error chain registration requires options.select + core.register({ name: 'chain.takeover' }, Takeover) + + // Drifted chain component: demands a matched shape the selector cannot + // supply (NoInfer pins M to the select return — the component position + // must not widen it). + // @ts-expect-error component matched prop drifts from the select return + core.register({ + name: 'chain.takeover', + select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null, + }, NarrowTakeover) + + // select must return M | null, not undefined (find() must be coalesced). + // @ts-expect-error select may not return undefined + core.register({ + name: 'chain.takeover', + select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'), + }, Takeover) + + // Chain keys are not renderSlot-dispatchable (and vice versa). + // @ts-expect-error chain keys dispatch through renderSlotChain only + chainSlots.renderSlot('chain.takeover', { items: [] }) + // @ts-expect-error non-chain keys have no renderSlotChain dispatch + chainSlots.renderSlotChain('chain.conv', {}) + // @ts-expect-error a children set without chain keys provides no renderSlotChain + fp.renderSlotChain + // renderSlot owner share typed at the call site. // @ts-expect-error owner shape mismatch (width missing) fp.renderSlot('chain.side', { collapsed: false }) diff --git a/packages/client/web-react/README.md b/packages/client/web-react/README.md index fcc67a8ec2..704e09d96f 100644 --- a/packages/client/web-react/README.md +++ b/packages/client/web-react/README.md @@ -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 diff --git a/packages/client/web-react/src/index.ts b/packages/client/web-react/src/index.ts index cd3189b891..b5c975305a 100644 --- a/packages/client/web-react/src/index.ts +++ b/packages/client/web-react/src/index.ts @@ -22,7 +22,7 @@ export type UseSession = SnapshotSelectorHook /** 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 } 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() + +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 + } + 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) => ( - + ) @@ -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, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 8cbbf21be4..40fed207f0 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -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 type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode +type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode }) => ReactNode type DeclaredSpec = SlotSpec /** Entry literal helper: fake entries default the mandatory options bag. */ const entryOf = (partial: Omit & { 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 & { 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, 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 & { 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, 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(() => never) + h.add('k.chain', chainEntryOf({ + component: declinerBody, + select: () => null, + })) + h.add('k.chain', chainEntryOf({ + component: ({ matched }: { matched?: { label: string } }) => {matched?.label}, + 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 }) => {matched}, + select: (owner) => (owner as { pick?: string }).pick ?? null, + })) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <> +
{renderSlotChain('k.chain', {}, { fallback: bar })}
+ + ) + // 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: none })) + expect(view.container.textContent).toBe('none') + let dispose = () => {} + act(() => { + dispose = h.add('k.chain', chainEntryOf({ + component: () => IN, + 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: () => late, + select: () => ({}), + priority: 2, + })) + h.add('k.chain', chainEntryOf({ + component: () => early, + select: () => ({}), + priority: 1, + })) + // Tie pair at priority 1: registration order decides (early wins over tie). + h.add('k.chain', chainEntryOf({ + component: () => tie, + 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: fb }) + }) + 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()