refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -1,18 +1,23 @@
/**
* Slot registry pure core. Owners declare slot contracts by merging into
* {@link SlotMap}; `define` records the runtime spec, `register` contributes a
* component. Zero runtime dependencies (React types only).
* Slot registry pure core (slot terminal design). Owners declare slot
* contracts by merging into {@link SlotMap}; one `register` call contributes a
* component AND (optionally) declares child slots, a store seat, and the
* registrant's business face. Zero runtime dependencies (React types only).
*
* SlotMap and its companion types live directly in this entry module: consumer
* `declare module` augmentation merges with declarations lexically in the
* augmented module, not with re-exports.
* SlotMap and the standard-kit interfaces live directly in this entry module:
* consumer `declare module` augmentation merges with declarations lexically in
* the augmented module, not with re-exports.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in THIS compilation unit (so the intersection reads as `never`), but every
* consumer merges keys in and the intersection is what keeps them string-typed.
* The rule fires on the empty-map view, not on real redundancy. */
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type { BoundActions, HandleOf, PropsStore, StoreDecl } from './store.ts'
export * from './store.ts'
export * from './renderer.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
@@ -24,147 +29,207 @@ export type SlotKind = 'single' | 'list' | 'keyed'
export type SlotScope = 'root' | 'session'
/**
* One SlotMap entry: kind/scope axes, the owner-supplied props share, and an
* optional sub-slot whitelist. Ownership rule (a share's type lives with
* whoever wires it): `owner` is the render-side share declared by the
* slot-owning package and REFERENCED by registrants; the registrant's own
* injected share never enters this table — full component props are composed
* at the component as `OwnerOf<K> & <injector-narrowed standard> & OwnInjected`.
* `props` is the legacy full-props slot kept while consumers migrate to
* composed declarations (new entries declare `owner` and omit it).
* One SlotMap entry: kind/scope axes plus the optional owner-supplied props
* share (`owner` is what the parent passes at its renderSlot call site; the
* framework standard kit and the registrant's injected share never enter this
* table — full component props compose at the component as the four-share
* intersection, see {@link ComposedProps}).
*/
export interface SlotEntryDef {
kind: SlotKind
scope: SlotScope
props?: object
owner?: object
children?: keyof SlotMap & string
}
/**
* Owner-supplied props share for a slot key: the render-side contract half.
* Falls back to the legacy Partial form when the entry declares no `owner`.
* Runtime dispatch spec for one slot, recorded from a register call's
* `children` value. The literal is compile-time checked against the SlotMap
* entry (`SlotSpec<SlotMap[P]>` in {@link ChildrenDecl}), so type and value
* are declared at one point and validate each other.
*/
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : OwnerProps<SlotMap[K]>
/**
* CONSTRAINT-position standard share for register(): the framework supplies
* session slots' bound selector hook, so it is bottom-typed here — any
* registrant narrowing (e.g. a runtime-typed conversation hook) is accepted,
* and the type responsibility for what actually arrives lives with the
* injecting side (web-react's renderer). Do NOT compose component props from
* this type; declare the narrowed hook the component actually consumes.
*/
export type StandardOf<K extends keyof SlotMap & string> =
SlotMap[K]['scope'] extends 'session' ? { useSession: never } : object
/**
* Delegable sub-slot whitelist declared by an entry: the `children` key union,
* or `never` when the entry declares none (no delegation authorized).
*/
export type ChildrenOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { children: infer C extends keyof SlotMap & string } ? C : never
/**
* B-b optional validation layer over the hand-written whitelist (B-a): the
* composed register constraint carries a `slots` face whose key union is the
* entry's `children` declaration. Components wanting a SUBSET whitelist
* accept it through ScopedSlots covariance; an out-of-union whitelist makes
* the component's `slots` parameter unsatisfiable and the register call site
* reports it. Entries without `children` add no slots constraint, so any
* hand-written whitelist registers freely (the layer is opt-in per entry).
* This is constraint-side only: whether slots are actually delivered stays
* with the renderer/owner (B-a trust).
*/
export type SlotsFaceOf<K extends keyof SlotMap & string> =
[ChildrenOf<K>] extends [never]
? object
: { slots: ScopedSlots<ChildrenOf<K>> }
/**
* The registration-boundary composed props constraint: owner share + standard
* share + registrant share, gated by the entry's `children` authorization
* (see {@link ChildrenChecked}). Entries without an `owner` declaration
* (legacy full-props form) keep the plain props constraint until they migrate.
*/
export type ComposedProps<K extends keyof SlotMap & string, I extends object> =
SlotMap[K] extends { owner: infer O extends object }
? O & StandardOf<K> & SlotsFaceOf<K> & I
: PropsShape<SlotMap[K]>
/**
* Registration-position component shape: the bare call signature, so the
* ComposedProps constraint checks through clean parameter contravariance
* (FC's propTypes/defaultProps statics add covariant noise that rejects
* legitimate narrowings of the bottom-typed standard share).
*/
export type SlotComponent<P> = (props: P) => ReactNode
/** The stored component's props shape: the legacy full-props slot, or wide for composed entries. */
export type PropsShape<E extends SlotEntryDef> =
E extends { props: infer P extends object } ? P : object
/**
* Session-scoped assembly handle passed to inject factories (apply world
* only, never into props). `Ctx` defaults to unknown at this zero-dependency
* layer; runtime re-exports the ClientContext-narrowed alias.
*/
export interface SessionBinding<Ctx = unknown> {
readonly sessionId: string
readonly session: SessionAccess
readonly ctx: Ctx
}
/** Root-scoped assembly handle passed to inject factories. */
export interface RootBinding<Ctx = unknown> { readonly ctx: Ctx }
/** Session subscription surface; web-react narrows `useSelector` to the typed hook. */
export interface SessionAccess { readonly useSelector: unknown }
/**
* Factory producing the registrant's private injected props, called once per
* (entry x session) for session slots or per entry for root slots. `I` is the
* registrant's own injected share, inferred at the registration site. `Ctx`
* parameterizes the binding's context (default unknown keeps this layer
* dependency-free); runtime's narrowed binding aliases flow through here so
* factories written against a narrowed ctx type-check without a cast.
*/
export type InjectFactory<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
(b: E['scope'] extends 'session' ? SessionBinding<Ctx> : RootBinding<Ctx>) => I
/** Runtime spec recorded at define time; must match the SlotMap declaration. */
export interface SlotSpec<E extends SlotEntryDef> { kind: E['kind']; scope: E['scope'] }
/**
* Registration options, shaped by the slot kind and the registrant's injected
* share `I`. `Ctx` flows through to the inject factory's binding parameter
* (narrowing wrappers fix it to their client context type).
* Child-slot declaration table for register(): keys are the declared (and
* thereby render-authorized) slot names, values are their runtime dispatch
* specs. Declaring is claiming: the registering entry becomes the only entry
* allowed to render these keys.
*/
export type SlotOptions<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
E['kind'] extends 'keyed' ? { key: string; inject?: InjectFactory<E, I, Ctx> }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: string; inject?: InjectFactory<E, I, Ctx> }
: { inject?: InjectFactory<E, I, Ctx> }
export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]> }
/** register() trailing args: options are statically mandatory for keyed/list kinds (key/id live there). */
export type RegisterArgs<E extends SlotEntryDef, I extends object = Record<string, unknown>, Ctx = unknown> =
E['kind'] extends 'keyed' | 'list' ? [options: SlotOptions<E, I, Ctx>] : [options?: SlotOptions<E, I, Ctx>]
/** Owner-supplied props share for a slot key ({} for entries declaring no `owner`). */
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : object
/** One registered contribution: the component plus its registration options. */
export interface SlotEntry<E extends SlotEntryDef, I extends object = Record<string, unknown>> {
component: FC<PropsShape<E>>
options: SlotOptions<E, I>
/** Scope axis of a slot key's SlotMap entry. */
export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
/**
* Framework standard kit delivered to every session-scope slot component.
* Declared EMPTY here (zero-dependency layer): the runtime package merges the
* real members (`useSession` bound to the conversation snapshot and the
* framework-supplied `sessionId`) exactly as consumers merge SlotMap keys.
*/
export interface SessionStandardProps {}
/**
* Framework standard kit delivered to EVERY slot component (the global seat).
* Declared empty here; the runtime package merges `useSessions` (the session
* list selector hook — the sidebar tree's single derivation source).
*/
export interface GlobalStandardProps {}
/**
* The session id type as the runtime's SessionStandardProps merge declares it
* (branded); falls back to `string` in programs without the merge (this
* package's own tests).
*/
export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? S : string
/**
* Runtime props share for a slot key: owner share (parent's renderSlot call
* site) + session standard kit (session scope only) + the global seat.
*/
export type PropsRuntime<K extends keyof SlotMap & string> =
OwnerOf<K> &
(ScopeOf<K> extends 'session' ? SessionStandardProps : object) &
GlobalStandardProps
/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Child-slot render share: `renderSlot` statically narrowed to the entry's
* declared children keys. Delegation is plain props passing (hand
* `props.renderSlot` down); the authorizing identity stays the registering
* entry. `__renders` is a phantom variance anchor (never materialized):
* generic method signatures compare loosely across differing key unions, so
* this contravariant marker is what actually enforces "component key set ⊆
* children declaration" at the register call site.
*/
export type PropsRenderSlots<S extends keyof SlotMap & string> = {
/**
* Render a declared child slot.
* @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
readonly __renders?: ((key: S) => void) | undefined
}
/** Type-erased stored entry; public typing is restored at the entries() boundary. */
interface StoredEntry {
/**
* Registration-position component shape: the bare call signature, so composed
* constraints check through clean parameter contravariance (FC statics add
* covariant noise rejecting legitimate narrowings).
*/
export type SlotComponent<P> = (props: P) => ReactNode
/**
* The four-share component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face. Each share derives from its single
* source of truth; components reference this composition, never re-type it.
*/
export type ComposedProps<
K extends keyof SlotMap & string,
S extends keyof SlotMap & string,
H,
I extends object,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I
/**
* Inject factory parameter list, derived from the registration's declaration:
* session slots receive the framework-resolved `sessionId`; a declared store
* appends the baked `actions` (the same callbacks the component receives);
* root slots without a store take no parameters. Business data access happens
* through the apply closure's ctx — no binding object parameter exists.
*/
export type InjectParams<K extends keyof SlotMap & string, H> =
ScopeOf<K> extends 'session'
? ([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> =
E['kind'] extends 'keyed' ? { key: string }
: E['kind'] extends 'list' ? { id: string; order?: number; label?: string }
: 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.
*/
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 })
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
store?: H
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K]>
/**
* One stored registration, as recorded by the core and read by the render
* machinery (type-erased at this boundary; the register seam already proved
* the shares against the component).
*/
export interface StoredEntry {
component: unknown
options: { key?: string; id?: string; order?: number; label?: string; inject?: unknown }
options: { key?: string; id?: string; order?: number; label?: string }
/** 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). */
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
store?: StoreDecl | undefined
/** Diagnostics label of who registered. */
registrant?: string | undefined
}
/** Per-key registry record. Created on first define/subscribe/version read; never removed (version stays monotonic across redefines). */
/**
* Type-erased options view the implementation works with. Optional members
* carry explicit `| undefined`: under exactOptionalPropertyTypes the public
* overloads (whose generics admit undefined) would otherwise fail
* overload-to-implementation compatibility.
*/
interface ErasedOptions {
name: string
key?: string | undefined
id?: string | undefined
order?: number | undefined
label?: string | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
* against the per-declaration InjectParams tuples. */
inject?: ((...args: any) => Record<string, unknown>) | undefined
registrant?: string | undefined
}
/**
* Per-key registry record. Created on first touch; never removed (version
* stays monotonic across redeclarations).
*/
interface SlotRecord {
spec: SlotSpec<SlotEntryDef> | undefined
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
declaredBy: string | undefined
entries: readonly StoredEntry[]
version: number
listeners: Set<() => void>
@@ -173,8 +238,11 @@ interface SlotRecord {
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
/**
* Pure slot registry (no cordis; event emission lives in the runtime Service
* wrapper via {@link SlotCore.onMutate}).
* Pure slot registry (no cordis; event emission and the renderer install seam
* live in the runtime Service wrapper).
*
* The 'root' slot is the one a-priori declaration, seeded at construction
* (single/root, declared by the framework) — the render tree's root hole.
*
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
* fires synchronously per mutation (registry state is consistent when they
@@ -184,101 +252,177 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
export class SlotCore {
private records = new Map<string, SlotRecord>()
private mutateListeners = new Set<(key: string) => void>()
/** Shared-handle scope ledger: handle → the scope it first mounted under + live mount count. */
private handleScopes = new Map<object, { scope: SlotScope; count: number }>()
// Dirty records, not keys: records are never removed, so holding the
// reference skips a lookup (and an unreachable missing-record branch) at flush.
private dirty = new Set<SlotRecord>()
private flushScheduled = false
constructor() {
// The a-priori root hole. No markDirty: nothing can observe construction.
const root = this.record('root')
root.spec = { kind: 'single', scope: 'root' }
root.declaredBy = '(built-in)'
}
/**
* Record a slot's runtime spec. Registering into an undefined key throws;
* defining an already-defined key throws (one owner per slot).
* @param key - SlotMap key.
* @param spec - kind/scope spec matching the declaration.
* @returns disposer removing the definition and its entries (idempotent; a
* stale disposer after redefine is a no-op).
* Contribute a component to a declared slot and (optionally) declare child
* slots, a store seat, and the registrant's business face — the single
* composition API (the separate define API is retired).
*
* Load-time validation (misconfiguration fails loud; the render hot path
* re-checks nothing): registering into an undeclared slot throws; declaring
* an already-declared child key throws (one declarer per slot — the message
* 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.
*
* Lifecycle: the disposer removes the contribution AND collapses every
* declared child slot (child entries clear recursively; their stale
* disposers become no-ops) — one lifecycle axis, no dangling state.
*
* @param options - registration options: target `name`, `children`
* declaration table, `store` seat, `inject` business-face factory, kind
* shape fields (keyed `key`; list `id`/`order`/`label`).
* @param component - component honoring the four-share composed props
* contract ({@link ComposedProps}); checked at this call site.
* @returns disposer removing the registration and its declarations
* (idempotent; stale disposers after a cascade are no-ops).
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
const rec = this.record(key)
if (rec.spec) throw new Error(`slot "${String(key)}" is already defined`)
const recorded: SlotSpec<SlotEntryDef> = spec
rec.spec = recorded
this.markDirty(key, rec)
register<
K extends keyof SlotMap & string,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object>>
& RendersCheck<C, D>,
): () => void
register<
K extends keyof SlotMap & string,
I extends object,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I>>
& RendersCheck<C, D>,
): () => void
register(options: ErasedOptions, component: unknown): () => void {
const rec = this.records.get(options.name)
if (!rec?.spec) {
throw new Error(`slot "${options.name}" is not declared (a parent entry's children table must declare it)`)
}
const spec = rec.spec
// Kind constraints stay runtime checks for dynamically-composed callers;
// typed callers already satisfied KindOptions statically.
switch (spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`)
break
case 'keyed':
if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`)
if (rec.entries.some(e => e.options.key === options.key)) {
throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}"`)
}
break
case 'list':
if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
if (rec.entries.some(e => e.options.id === options.id)) {
throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`)
}
break
}
if (options.children) {
for (const childKey of Object.keys(options.children)) {
const childRec = this.records.get(childKey)
if (childRec?.spec) {
throw new Error(`slot "${childKey}" is already declared (by ${childRec.declaredBy ?? 'an unknown entry'})`)
}
}
}
// Shared handles pin their scope on first mount; factories are exempt
// (the framework creates per-entry instances, no shared identity exists).
if (options.store !== undefined && typeof options.store !== 'function') {
const pinned = this.handleScopes.get(options.store)
if (pinned && pinned.scope !== spec.scope) {
throw new Error(
`store handle mounted under "${options.name}" (scope "${spec.scope}") is already mounted under scope "${pinned.scope}" — one handle, one scope`)
}
if (pinned) pinned.count += 1
else this.handleScopes.set(options.store, { scope: spec.scope, count: 1 })
}
const entry: StoredEntry = {
component,
options: {
...(options.key !== undefined ? { key: options.key } : {}),
...(options.id !== undefined ? { id: options.id } : {}),
...(options.order !== undefined ? { order: options.order } : {}),
...(options.label !== undefined ? { label: options.label } : {}),
},
...(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.
if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
rec.entries = next
this.markDirty(options.name, rec)
if (options.children) {
for (const [childKey, childSpec] of Object.entries(options.children)) {
const childRec = this.record(childKey)
childRec.spec = childSpec
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
this.markDirty(childKey, childRec)
}
}
return () => {
if (rec.spec !== recorded) return
rec.spec = undefined
rec.entries = NO_ENTRIES
this.markDirty(key, rec)
if (!rec.entries.includes(entry)) return
rec.entries = rec.entries.filter(e => e !== entry)
this.markDirty(options.name, rec)
this.releaseEntry(entry)
}
}
/**
* Contribute a component to a defined slot. single: duplicate registration
* throws; keyed: missing or duplicate `options.key` throws; list: missing or
* duplicate `options.id` throws (duplicates would make `only`/`entryKey`
* dispatch ambiguous).
* @param key - SlotMap key.
* @param component - component honoring the entry's composed props contract (owner & standard & injected shares).
* @param args - kind-shaped registration options; statically mandatory for keyed/list (key/id live there).
* @returns disposer removing the registration (idempotent; a stale disposer
* after the slot's define disposer ran is a no-op).
* Whether a previously obtained entry is still registered (the render
* machinery's stale-authorization probe: a retained renderSlot binding
* whose entry left the ledger must not render).
* @param entry - a previously read entry.
* @returns false once the entry's registration was disposed.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>, Ctx = unknown>(
// NoInfer pins I's inference to the inject factory: letting the component
// position drive I would absorb any props drift into the constraint.
// Ctx flows from the options' inject-factory parameter annotation
// (narrowing wrappers fix it; the core stays context-agnostic).
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>, ...args: RegisterArgs<SlotMap[K], I, Ctx>): () => void {
const rec = this.records.get(key)
if (!rec?.spec) throw new Error(`slot "${String(key)}" is not defined`)
const opts = (args[0] ?? {}) as StoredEntry['options']
// keyed/list options are statically mandatory (RegisterArgs); the runtime
// checks below stay for dynamically-composed callers.
switch (rec.spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${String(key)}" already has a registration`)
break
case 'keyed':
if (opts.key === undefined) throw new Error(`keyed slot "${String(key)}" requires options.key`)
if (rec.entries.some(e => e.options.key === opts.key)) {
throw new Error(`keyed slot "${String(key)}" already has an entry for key "${opts.key}"`)
}
break
case 'list':
if (opts.id === undefined) throw new Error(`list slot "${String(key)}" requires options.id`)
if (rec.entries.some(e => e.options.id === opts.id)) {
throw new Error(`list slot "${String(key)}" already has an entry with id "${opts.id}"`)
}
break
}
const entry: StoredEntry = { component, options: opts }
const next = [...rec.entries, entry]
// Stable sort: order ascending, ties keep registration sequence.
if (rec.spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
rec.entries = next
this.markDirty(key, rec)
return () => {
if (!rec.entries.includes(entry)) return
rec.entries = rec.entries.filter(e => e !== entry)
this.markDirty(key, rec)
isLive(entry: StoredEntry): boolean {
for (const rec of this.records.values()) {
if (rec.entries.includes(entry)) return true
}
return false
}
/**
* Snapshot the registered entries for a key. Returns the cached array
* reference (stable between mutations — safe as a uSES getSnapshot source);
* empty for keys not (or no longer) defined, so renderers may probe ahead of
* plugin load order.
* @param key - SlotMap key.
* empty for keys not (or no longer) declared, so renderers may probe ahead
* of plugin load order.
* @param key - slot key (dynamic: the render machinery holds keys as strings).
* @returns entries in registration (list: order) sequence.
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
return (this.records.get(key)?.entries ?? NO_ENTRIES) as unknown as readonly SlotEntry<SlotMap[K]>[]
entries(key: string): readonly StoredEntry[] {
return this.records.get(key)?.entries ?? NO_ENTRIES
}
/**
* Look up a slot's defined spec, narrowed by the SlotMap key.
* Look up a slot's declared spec, narrowed by the SlotMap key.
* @param key - SlotMap key.
* @returns the spec, or undefined before define.
* @returns the spec, or undefined while undeclared.
*/
spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
return this.records.get(key)?.spec as SlotSpec<SlotMap[K]> | undefined
@@ -289,7 +433,7 @@ export class SlotCore {
* only hold as strings (generic dispatch) use this wide form; statically
* keyed callers use {@link SlotCore.spec}.
* @param key - candidate slot key.
* @returns the wide-typed spec, or undefined before define.
* @returns the wide-typed spec, or undefined while undeclared.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this.records.get(key)?.spec
@@ -297,12 +441,12 @@ export class SlotCore {
/**
* Subscribe to registration changes for a key (microtask-batched).
* Subscribing ahead of define is allowed; the define itself notifies.
* @param key - SlotMap key.
* Subscribing ahead of declaration is allowed; the declaration notifies.
* @param key - slot key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
subscribe(key: string, fn: () => void): () => void {
const rec = this.record(key)
rec.listeners.add(fn)
return () => { rec.listeners.delete(fn) }
@@ -311,10 +455,10 @@ export class SlotCore {
/**
* Monotonic version for a key, bumped synchronously per mutation so a
* uSES getSnapshot read is never stale when its batched notification lands.
* @param key - SlotMap key.
* @param key - slot key.
* @returns current version (0 for untouched keys).
*/
getVersion(key: keyof SlotMap & string): number {
getVersion(key: string): number {
return this.records.get(key)?.version ?? 0
}
@@ -330,10 +474,35 @@ export class SlotCore {
return () => { this.mutateListeners.delete(fn) }
}
/**
* Cascade for a removed entry: release its store mount and collapse every
* child slot it declared — specs clear, contributions empty (their stale
* disposers no-op), recursively down the declaration tree. One lifecycle
* axis: ledger rows, slots, contributions, and store mounts die together.
*/
private releaseEntry(entry: StoredEntry): void {
if (entry.store !== undefined && typeof entry.store !== 'function') {
const pinned = this.handleScopes.get(entry.store)
if (pinned && --pinned.count === 0) this.handleScopes.delete(entry.store)
}
if (!entry.children) return
for (const childKey of Object.keys(entry.children)) {
const childRec = this.records.get(childKey)
/* v8 ignore next -- defensive: declaring always creates the record */
if (!childRec) continue
const doomed = childRec.entries
childRec.spec = undefined
childRec.declaredBy = undefined
childRec.entries = NO_ENTRIES
this.markDirty(childKey, childRec)
for (const dead of doomed) this.releaseEntry(dead)
}
}
private record(key: string): SlotRecord {
let rec = this.records.get(key)
if (!rec) {
rec = { spec: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
this.records.set(key, rec)
}
return rec
@@ -359,49 +528,3 @@ export class SlotCore {
}
}
}
/**
* Whitelist-narrowed render surface handed to owner components via props
* (implementation lives in web-react's scopedSlots factory).
*/
export interface ScopedSlots<K extends keyof SlotMap & string> {
/**
* Render a slot's registered entries.
* @param key - whitelisted SlotMap key.
* @param props - owner-supplied share of the entry's props contract.
* @param opts - render options.
* @returns rendered node(s).
*/
renderSlot: <Key extends K>(key: Key, props: OwnerOf<Key>, opts?: RenderOpts) => ReactNode
/**
* Phantom variance anchor (never materialized at runtime): generic method
* signatures compare loosely across differing key-union constraints, so
* this contravariant marker is what actually enforces "a surface is
* assignable only where its whitelist covers the target's keys".
*/
readonly __accepts?: ((key: K) => void) | undefined
}
/** renderSlot options: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode }
/**
* Narrow a slots surface to a subset whitelist for delegation to a child
* component (`K2` ⊆ `K1`). Pure type narrowing — ScopedSlots is covariant in
* its key union, so the same object is returned.
* @param slots - the owner's wider surface.
* @returns the same surface, typed to the subset.
*/
export function narrowSlots<K2 extends K1, K1 extends keyof SlotMap & string>(
slots: ScopedSlots<K1>): ScopedSlots<K2> {
return slots
}
/**
* The owner-supplied share of an entry's props. `useSession` is excluded (the
* framework injects it on session slots; owners must not shadow the bound
* hook). Registrant inject keys are per-registration and unknowable at the
* type level, so the remaining share stays Partial rather than exact.
*/
export type OwnerProps<E extends SlotEntryDef> =
E extends { props: infer P extends object } ? Partial<Omit<P, 'useSession'>> : object

View File

@@ -0,0 +1,116 @@
/**
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
* web-react's machinery implements, the host surface the runtime SlotsService
* presents to the installed renderer, and the render-path authorization
* errors. Pure types plus two error classes — this package stays React-free
* at runtime (React types only).
*/
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
/** Minimal observable surface for host-provided standard-kit data sources. */
export interface HostObservable<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Type-erased store instance face at the render seam (the typed twin is
* {@link StoreInstance}): selector hook plus draft-stripped action callbacks.
* Typing lands at the component seam via {@link PropsStore}.
*/
export interface StoreInstanceLike {
readonly useSelector: unknown
readonly actions: Record<string, (...params: never[]) => void>
}
/** Session standard kit resolved per session id (identity-stable per session scope; a recreated scope yields a new cell). */
export interface SessionCell {
sessionId: string
/** Bound conversation-snapshot selector hook (wide here; runtime narrows at its export seam). */
useSession: unknown
}
/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */
export interface RenderOpts {
entryKey?: string
only?: string
fallback?: ReactNode
}
/** Host surface the runtime SlotsService presents to the installed renderer. */
export interface SlotRendererHost {
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - slot key.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(key: string, fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @param key - slot key.
* @returns current version.
*/
getVersion(key: string): number
/**
* Snapshot the registered entries for a key (stable reference between mutations).
* @param key - slot key.
* @returns entries in registration (list: order) sequence.
*/
entriesOf(key: string): readonly StoredEntry[]
/**
* Declared runtime spec from the declarations ledger.
* @param key - slot key.
* @returns the spec, or undefined while the key is undeclared (outlets render empty).
*/
specOf(key: string): SlotSpec<SlotEntryDef> | undefined
/**
* Stale-authorization check: whether the entry is still in the ledger.
* @param entry - a previously rendered entry.
* @returns false once the entry's registration was disposed.
*/
isLive(entry: StoredEntry): boolean
/**
* Resolve (create or return cached) the store instance for an entry's
* declared handle under a scope key; lifecycle rides the ledger axis.
* @param entry - entry whose declaration carries the handle.
* @param scopeKey - session id for session-scope slots, undefined for root scope.
* @returns the instance, or undefined when the entry declares no store.
*/
storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined
/** Session-side standard-kit sources. */
sessions: {
/** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown>
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
current: HostObservable<string | undefined>
/**
* Resolve the session standard kit.
* @param id - session id.
* @returns the cell, or undefined for an unknown session (provider falls to empty).
*/
cell(id: string): SessionCell | undefined
}
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
export interface SlotRenderer {
/**
* Render the root slot tree over the host surface (the only ctx-level entry).
* @param host - the installing service's host surface.
* @param ownerProps - owner props from the shell's renderSlot('root', ...) call.
* @returns the rendered tree.
*/
renderRoot(host: SlotRendererHost, ownerProps: object): ReactNode
}
/** Thrown when a retained renderSlot binding is invoked after its declaring entry was disposed. */
export class StaleAuthorizationError extends Error {}
/**
* Thrown when a renderSlot binding is invoked for a key outside its entry's
* children declaration (plain-JS backstop; typed callers are narrowed
* statically).
*/
export class SlotOwnershipError extends Error {}

View File

@@ -0,0 +1,137 @@
/**
* Store-seat type family (slot terminal design §4): a registrant declares its
* shared/exclusive business store as data — schema (`init`), optional
* persistence key, and the complete write set (`actions`) — and the framework
* owns instance lifecycle (scope derives from the mounting entry's slot).
* ui-slots ships the contract types only; the engine-backed `defineStore`
* value lives in web-react (the snapshot-store engine's home) and must
* satisfy {@link DefineStore}.
*/
/**
* Typed selector hook over a snapshot source. Canonical shape for the whole
* slot system (web-react's engine hook is structurally identical; the
* framework is the only party that ever constructs one).
*/
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
/**
* Action declaration table: pure immer-draft transforms over the store state,
* declared as the store's complete write set (the audit face — components can
* only write through these).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* any[] (not unknown[]): each action carries its own parameter list, and
* unknown[] would reject every concrete signature under strict parameter
* contravariance. Params are re-inferred per action by BakedActions. */
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
/**
* Draft-stripped callback form of an actions table: what components
* (`props.actions`) and inject factories receive — the framework bakes the
* draft parameter away by binding each action to the resolved instance.
*/
export type BakedActions<T, A extends ActionsDecl<T>> = {
[K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
}
/**
* Store declaration spec: initial-state factory (a lambda so every instance
* gets a fresh state), optional persistence key (mechanical, framework-run),
* and the actions write set.
*/
export interface StoreSpec<T, A extends ActionsDecl<T>> {
/** Initial-state factory; called once per framework-created instance. */
init: () => T
/** Opt-in persistence key (storage mechanics belong to the engine). */
persist?: string
/** Complete write set: pure draft transforms. */
actions: A
}
/**
* Live engine instance: the create() product consumed by the render machinery
* and by component tests (fed straight into props as useStore/actions).
* Production components and render paths never call create() themselves —
* instance lifecycle is the framework's.
*/
export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Selector hook bound to this instance (delivered to components as `useStore`). */
readonly useSelector: SnapshotSelectorHook<T>
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A>
/** Current state snapshot (test assertions; machinery). */
getSnapshot(): T
/**
* Subscribe to state changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Drop this instance's persisted value (no-op for non-persist specs). The
* framework calls it when the owning scope dies for good — a pruned session
* must not leave orphaned storage keys behind.
*/
clearPersisted(): void
}
/**
* Store handle: spec + state/actions types + shared identity + instance
* factory in one value. Handles are constructed in apply world (shared across
* registrations of one plugin) or by the framework from a registrant's
* factory (exclusive). Never export a handle at module level — module-cache
* identity is a disguised singleton across plugin reloads.
*/
export interface StoreHandle<T, A extends ActionsDecl<T>> {
/** The inert declaration this handle was defined from. */
readonly spec: StoreSpec<T, A>
/**
* Create a live engine instance (framework machinery and tests only).
* @param scopeKey - session id for session-scope instances; suffixes the
* persist key so per-session instances persist independently (root-scope
* instances omit it).
* @returns a fresh instance seeded from `spec.init()`.
*/
create(scopeKey?: string): StoreInstance<T, A>
}
/**
* Exclusive-store registration form: the registrant passes the factory itself
* and the framework calls it per entry x scope (no shared identity exists).
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* erased position accepting every StoreHandle instantiation; T/A are
* recovered per use site by conditional inference (HandleOf/BoundActions/
* PropsStore). */
export type StoreFactory = () => StoreHandle<any, any>
/** The register `store` option position: a shared handle or an exclusive factory. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
export type StoreDecl = StoreHandle<any, any> | StoreFactory
/** Normalize a store declaration to its handle type (factories yield their return). */
export type HandleOf<H> = H extends () => infer R ? R : H
/**
* Handle-keyed baked actions: the `actions` parameter of an inject factory
* whose registration declared a store — the same baked callback set the
* component receives via {@link PropsStore}.
*/
export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
/**
* The store props share, derived from the declared handle: a typed selector
* hook plus the baked write set. Components never see the instance itself
* (no update/set — reads via useStore, writes via the declared actions only).
*/
export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
: object
/**
* The defineStore contract (implementation lives in web-react, bound to the
* snapshot-store engine): spec in, handle out, with T inferred from `init`
* and the actions table constrained by T.
*/
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>