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
+85 -22
View File
@@ -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) {