feat(gui): chain slot kind with select routing and renderSlotChain
This commit is contained in:
@@ -11,11 +11,13 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co
|
||||
| store | `PropsStore<H>` | 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<T, A>` 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
|
||||
|
||||
|
||||
@@ -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<K extends keyof SlotMap & string> =
|
||||
/** 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<O extends object, M> = (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 keyof SlotMap & string> =
|
||||
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 extends SlotEntryDef, M> =
|
||||
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<S extends keyof SlotMap & string> = {
|
||||
/**
|
||||
* 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: <K extends S>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
|
||||
renderSlot: <K extends Exclude<S, ChainKeysOf<S>>>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
|
||||
readonly __renders?: ((key: S) => void) | undefined
|
||||
} & ('session' extends ScopeOf<S>
|
||||
} & ([ChainKeysOf<S>] 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: <K extends ChainKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: ChainRenderOpts) => ReactNode
|
||||
}) & ('session' extends ScopeOf<S>
|
||||
// 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<K> & PropsRenderSlots<S> & PropsStore<H> & I
|
||||
M = never,
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
|
||||
|
||||
/**
|
||||
* Inject factory parameter list, derived from the registration's declaration:
|
||||
@@ -182,27 +223,35 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
|
||||
? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
|
||||
: ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
|
||||
|
||||
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label). */
|
||||
export type KindOptions<E extends SlotEntryDef> =
|
||||
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */
|
||||
export type KindOptions<E extends SlotEntryDef, M = never> =
|
||||
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<E extends { owner: infer O extends object } ? O : object, M>
|
||||
/** 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<C, D> =
|
||||
[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<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
|
||||
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
|
||||
/** 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<K extends keyof SlotMap & string, D extends ChildrenDecl, H> =
|
||||
store?: H
|
||||
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
|
||||
registrant?: string
|
||||
} & KindOptions<SlotMap[K]>
|
||||
} & KindOptions<SlotMap[K], M>
|
||||
|
||||
/**
|
||||
* One stored registration, as recorded by the core and read by the render
|
||||
@@ -220,7 +269,9 @@ type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> =
|
||||
*/
|
||||
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<string, unknown>) | 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<string, SlotSpec<SlotEntryDef>> | 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<never, never>,
|
||||
H extends StoreDecl | undefined = undefined,
|
||||
M = never,
|
||||
C extends SlotComponent<never> = SlotComponent<never>,
|
||||
>(
|
||||
options: BaseOptions<K, D, H> & { inject?: undefined },
|
||||
options: BaseOptions<K, D, H, M> & { inject?: undefined },
|
||||
component: C
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
|
||||
& RendersCheck<C, D>,
|
||||
): () => void
|
||||
/**
|
||||
@@ -348,11 +403,12 @@ export class SlotCore {
|
||||
I extends object,
|
||||
const D extends ChildrenDecl = Record<never, never>,
|
||||
H extends StoreDecl | undefined = undefined,
|
||||
M = never,
|
||||
C extends SlotComponent<never> = SlotComponent<never>,
|
||||
>(
|
||||
options: BaseOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
|
||||
options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
|
||||
component: C
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
|
||||
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
|
||||
& RendersCheck<C, D>,
|
||||
): () => 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) {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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<ReturnType<typeof createPanelStore>>): 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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user