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:
@@ -1,8 +1,21 @@
|
||||
# @deepseek-ai/dsh-client-ui-slots
|
||||
|
||||
Slot registry pure core: SlotMap declaration merging, SlotCore (single/list/keyed), ScopedSlots types. Contract: api-contracts v3 §1 + the slot type-chain design (composed-props registration).
|
||||
Slot registry pure core, slot terminal design: SlotMap declaration merging, the single `register` composition API on SlotCore, the four-share component-props type family, the store-seat type family, and the renderer install-seam contract. React types only at runtime — the package is React-free and cordis-free.
|
||||
|
||||
A SlotMap entry declares `{ kind; scope; owner; children? }`. `owner` is the render-side props share the slot-owning package declares; registrants reference it through `OwnerOf<K>` and never re-state it. The registrant's injected share `I` stays a local type at the registration site, inferred from the inject factory (`InjectFactory<E, I, Ctx>`; context-narrowing wrappers pin `Ctx`). `SlotCore.register` constrains the component against `ComposedProps<K, I>` — owner share & bottom-typed standard share (`StandardOf`) & `children`-gated slots face (`SlotsFaceOf`) & `I` — through the bare-call-signature `SlotComponent` position. `children` optionally whitelists delegable sub-slot keys (`ChildrenOf`; constraint-side validation only — delivery stays with the renderer); `narrowSlots` narrows a `ScopedSlots` surface to a subset whitelist.
|
||||
One `register({ name, children?, store?, inject?, ...kind }, Component)` call contributes a component into a declared slot and, in the same breath, declares child slots (declaration = render authorization = runtime spec, one table), a store seat, and the registrant's business face. The component is checked at the call site against `ComposedProps` — the intersection of four shares, each derived from its single source of truth:
|
||||
|
||||
| share | type | source |
|
||||
|---|---|---|
|
||||
| runtime | `PropsRuntime<K>` | SlotMap entry: `owner` (parent's renderSlot call site) + session standard kit + global seat |
|
||||
| child render | `PropsRenderSlots<S>` | the register call's `children` key set (statically narrowed `renderSlot`) |
|
||||
| store | `PropsStore<H>` | the declared handle: `useStore` selector hook + draft-stripped `actions` |
|
||||
| business | `I` | inferred from the `inject` factory's return |
|
||||
|
||||
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 web-react (the engine's home) and satisfies the `DefineStore` contract exported 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,5 +27,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`StandardOf` is a constraint-position bottom type (`useSession: never`), not the arriving hook type** — this zero-dependency layer cannot see the conversation snapshot; components declare the narrowed hook they consume, and what actually arrives is web-react's renderer responsibility.
|
||||
- **The legacy `props` entry member (with `OwnerProps`'s Partial owner share) remains for migration** — entries not yet declaring `owner` keep the P-I full-props constraint; both forms disappear with the last legacy declaration.
|
||||
- **`isLive` scans all records linearly** — fine at UI-plugin registration counts (tens); revisit with an entry→record backref if ledgers ever grow hot.
|
||||
- **The `__renders` phantom anchor is visible on `PropsRenderSlots`** — the same accepted noise as the type-chain design's `__accepts`: generic method signatures compare loosely across key unions, so the contravariant marker is what enforces "component key set ⊆ children declaration".
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-slots",
|
||||
"description": "Slot registry pure core: SlotMap declaration-merge surface, SlotCore (single/list/keyed), ScopedSlots types",
|
||||
"description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -1,165 +1,290 @@
|
||||
// SlotCore terminal-design behavior: the single register composition API —
|
||||
// a-priori 'root', children declaration/authorization, load-time validation,
|
||||
// one-axis lifecycle cascade, store scope pinning, subscription surface.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { RootBinding, SessionBinding, SlotOptions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// 'root' is NOT merged here: the runtime package owns the built-in row, and
|
||||
// the client aggregate program would see both merges collide.
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'test.single': { kind: 'single'; scope: 'root'; props: { label: string } }
|
||||
'test.list': { kind: 'list'; scope: 'root'; props: { label: string } }
|
||||
'test.keyed': { kind: 'keyed'; scope: 'session'; props: { label: string; useSession: unknown } }
|
||||
'test.single': { kind: 'single'; scope: 'root' }
|
||||
'test.session': { kind: 'single'; scope: 'session' }
|
||||
'test.list': { kind: 'list'; scope: 'root' }
|
||||
'test.keyed': { kind: 'keyed'; scope: 'session' }
|
||||
'test.grandchild': { kind: 'single'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
const Comp: FC<{ label: string }> = () => null
|
||||
const SessionComp: FC<{ label: string; useSession: unknown }> = () => null
|
||||
// Wide-accepting fixture: assignable wherever the composed constraint is an
|
||||
// object type (children-declaring fixtures erase via `as never` instead —
|
||||
// RendersCheck would demand a renderSlot consumer).
|
||||
const Comp: SlotComponent<object> = () => null
|
||||
|
||||
/** A minimal structurally-valid store handle (identity is what the ledger tracks). */
|
||||
function fakeHandle(): StoreHandle<{ n: number }, Record<string, (d: { n: number }) => void>> {
|
||||
return {
|
||||
spec: { init: () => ({ n: 0 }), actions: {} },
|
||||
create: () => { throw new Error('not under test') },
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a root-frame entry declaring the four test child slots. */
|
||||
function mountFrame(core: SlotCore) {
|
||||
return core.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'test.single': { kind: 'single', scope: 'root' },
|
||||
'test.session': { kind: 'single', scope: 'session' },
|
||||
'test.list': { kind: 'list', scope: 'root' },
|
||||
'test.keyed': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
// Type-level renderSlot presence is proven by the type-chain spec; erasing
|
||||
// here keeps runtime fixtures terse.
|
||||
}, Comp as never)
|
||||
}
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
describe('SlotCore kind semantics', () => {
|
||||
it('throws on register before define', () => {
|
||||
describe('a-priori root and declaration gate', () => {
|
||||
it('seeds root as single/root at construction', () => {
|
||||
const core = new SlotCore()
|
||||
expect(() => core.register('test.single', Comp)).toThrow('not defined')
|
||||
expect(core.specDynamic('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('throws on duplicate define', () => {
|
||||
it('throws on registering into an undeclared slot', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
expect(() => core.define('test.single', { kind: 'single', scope: 'root' })).toThrow('already defined')
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
|
||||
})
|
||||
|
||||
it('single: second registration throws, disposer frees the seat', () => {
|
||||
it('root is single: a second frame registration throws', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
const dispose = core.register('test.single', Comp)
|
||||
expect(() => core.register('test.single', Comp)).toThrow('already has a registration')
|
||||
dispose()
|
||||
mountFrame(core)
|
||||
expect(() => core.register({ name: 'root' }, Comp)).toThrow('already has a registration')
|
||||
})
|
||||
|
||||
it('children declaration makes child slots registerable, with specs recorded', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
expect(core.specDynamic('test.session')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
|
||||
})
|
||||
|
||||
it('duplicate child declaration throws naming the first declarer', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(() => core.register(
|
||||
{ name: 'test.session', children: { 'test.grandchild': { kind: 'single', scope: 'root' } }, registrant: 'imposter' },
|
||||
Comp as never,
|
||||
)).toThrow(/already declared.*test\.single/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle cascade (one axis)', () => {
|
||||
it('disposing a declaring entry collapses child slots and their contributions recursively', () => {
|
||||
const core = new SlotCore()
|
||||
const disposeFrame = mountFrame(core)
|
||||
const disposeChild = core.register(
|
||||
{ name: 'test.single', children: { 'test.grandchild': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
core.register({ name: 'test.grandchild' }, Comp)
|
||||
expect(core.entries('test.grandchild')).toHaveLength(1)
|
||||
|
||||
disposeFrame()
|
||||
expect(core.specDynamic('test.single')).toBeUndefined()
|
||||
expect(core.specDynamic('test.grandchild')).toBeUndefined()
|
||||
expect(core.entries('test.single')).toHaveLength(0)
|
||||
expect(() => core.register('test.single', Comp)).not.toThrow()
|
||||
expect(core.entries('test.grandchild')).toHaveLength(0)
|
||||
// Stale disposer of a cascaded-away entry is a no-op.
|
||||
expect(() => { disposeChild() }).not.toThrow()
|
||||
// Slots return to undeclared: contributing again throws until redeclared.
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('not declared')
|
||||
})
|
||||
|
||||
it('registration disposers are idempotent', () => {
|
||||
const core = new SlotCore()
|
||||
const dispose = mountFrame(core)
|
||||
dispose()
|
||||
dispose()
|
||||
expect(core.entries('root')).toHaveLength(0)
|
||||
// Redeclare works after collapse.
|
||||
mountFrame(core)
|
||||
expect(core.specDynamic('test.single')).toBeDefined()
|
||||
})
|
||||
|
||||
it('isLive tracks ledger membership across dispose', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const dispose = core.register({ name: 'test.single' }, Comp)
|
||||
const entry = core.entries('test.single')[0]!
|
||||
expect(core.isLive(entry)).toBe(true)
|
||||
dispose()
|
||||
expect(core.isLive(entry)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kind semantics', () => {
|
||||
it('keyed: duplicate key throws, missing key throws', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.keyed', { kind: 'keyed', scope: 'session' })
|
||||
core.register('test.keyed', SessionComp, { key: 'a' })
|
||||
expect(() => core.register('test.keyed', SessionComp, { key: 'a' })).toThrow('key "a"')
|
||||
// Statically rejected since RegisterArgs made keyed options mandatory;
|
||||
// the runtime guard stays for dynamically-composed callers.
|
||||
// @ts-expect-error keyed registration requires options
|
||||
expect(() => core.register('test.keyed', SessionComp)).toThrow('requires options.key')
|
||||
expect(() => core.register('test.keyed', SessionComp, { key: 'b' })).not.toThrow()
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.keyed', key: 'a' }, Comp)
|
||||
expect(() => core.register({ name: 'test.keyed', key: 'a' }, Comp)).toThrow('key "a"')
|
||||
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
|
||||
// @ts-expect-error keyed registration requires options.key
|
||||
expect(() => core.register({ name: 'test.keyed' }, Comp)).toThrow('requires options.key')
|
||||
expect(() => core.register({ name: 'test.keyed', key: 'b' }, Comp)).not.toThrow()
|
||||
})
|
||||
|
||||
it('list: duplicate id throws, missing id throws, entries sort by order stably', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'c', order: 10 })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
expect(() => core.register('test.list', Comp, { id: 'a' })).toThrow('id "a"')
|
||||
// @ts-expect-error list registration requires options (static since RegisterArgs)
|
||||
expect(() => core.register('test.list', Comp)).toThrow('requires options.id')
|
||||
const ids = core.entries('test.list').map(e => (e.options as { id: string }).id)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.list', id: 'c', order: 10 }, Comp)
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(() => core.register({ name: 'test.list', id: 'a' }, Comp)).toThrow('id "a"')
|
||||
// @ts-expect-error list registration requires options.id
|
||||
expect(() => core.register({ name: 'test.list' }, Comp)).toThrow('requires options.id')
|
||||
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('spec() exposes the definition; define disposer clears spec and entries', () => {
|
||||
it('single: second registration throws, disposer frees the seat', () => {
|
||||
const core = new SlotCore()
|
||||
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.register('test.single', Comp)
|
||||
expect(core.spec('test.single')).toEqual({ kind: 'single', scope: 'root' })
|
||||
mountFrame(core)
|
||||
const dispose = core.register({ name: 'test.single' }, Comp)
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).toThrow('already has a registration')
|
||||
dispose()
|
||||
expect(core.spec('test.single')).toBeUndefined()
|
||||
expect(core.entries('test.single')).toHaveLength(0)
|
||||
expect(() => core.register('test.single', Comp)).toThrow('not defined')
|
||||
})
|
||||
|
||||
it('disposers are idempotent and stale disposers after redefine are no-ops', () => {
|
||||
const core = new SlotCore()
|
||||
const disposeDef = core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
const disposeReg = core.register('test.single', Comp)
|
||||
disposeReg()
|
||||
disposeReg()
|
||||
disposeDef()
|
||||
disposeDef()
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.register('test.single', Comp)
|
||||
disposeDef()
|
||||
disposeReg()
|
||||
expect(core.spec('test.single')).toBeDefined()
|
||||
expect(core.entries('test.single')).toHaveLength(1)
|
||||
expect(() => core.register({ name: 'test.single' }, Comp)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SlotCore subscription surface', () => {
|
||||
describe('store scope pinning', () => {
|
||||
it('one shared handle under two scopes throws at load', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const handle = fakeHandle()
|
||||
core.register({ name: 'test.session', store: handle }, Comp as never)
|
||||
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never))
|
||||
.toThrow('one handle, one scope')
|
||||
})
|
||||
|
||||
it('same handle under same scope is fine; full unmount releases the pin', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const handle = fakeHandle()
|
||||
const d1 = core.register({ name: 'test.list', id: 'x', store: handle }, Comp as never)
|
||||
const d2 = core.register({ name: 'test.list', id: 'y', store: handle }, Comp as never)
|
||||
d1()
|
||||
// Still mounted once — scope stays pinned.
|
||||
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never))
|
||||
.toThrow('one handle, one scope')
|
||||
d2()
|
||||
// All mounts gone: the handle may pin a new scope.
|
||||
expect(() => core.register({ name: 'test.session', store: handle }, Comp as never)).not.toThrow()
|
||||
})
|
||||
|
||||
it('factories are exempt from pinning (no shared identity)', () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const factory = () => fakeHandle()
|
||||
core.register({ name: 'test.session', store: factory }, Comp as never)
|
||||
expect(() => core.register({ name: 'test.single', store: factory }, Comp as never)).not.toThrow()
|
||||
})
|
||||
|
||||
it('cascade releases store pins of collapsed child entries', () => {
|
||||
const core = new SlotCore()
|
||||
const disposeFrame = mountFrame(core)
|
||||
const handle = fakeHandle()
|
||||
core.register({ name: 'test.session', store: handle }, Comp as never)
|
||||
disposeFrame()
|
||||
mountFrame(core)
|
||||
expect(() => core.register({ name: 'test.single', store: handle }, Comp as never)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription surface', () => {
|
||||
it('entries() returns a stable cached reference between mutations', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
mountFrame(core)
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
const first = core.entries('test.list')
|
||||
expect(core.entries('test.list')).toBe(first)
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(core.entries('test.list')).not.toBe(first)
|
||||
})
|
||||
|
||||
it('bumps version synchronously but batches notifications per microtask', async () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
const fn = vi.fn()
|
||||
core.subscribe('test.list', fn)
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
expect(core.getVersion('test.list')).toBe(3)
|
||||
const before = core.getVersion('test.list')
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(core.getVersion('test.list')).toBe(before + 2)
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
await flushMicrotasks()
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
core.register('test.list', Comp, { id: 'c' })
|
||||
core.register({ name: 'test.list', id: 'c' }, Comp)
|
||||
await flushMicrotasks()
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('declaration itself notifies child-key subscribers (subscribe-ahead allowed)', async () => {
|
||||
const core = new SlotCore()
|
||||
const fn = vi.fn()
|
||||
core.subscribe('test.single', fn)
|
||||
mountFrame(core)
|
||||
await flushMicrotasks()
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => {
|
||||
const core = new SlotCore()
|
||||
mountFrame(core)
|
||||
await flushMicrotasks()
|
||||
const single = vi.fn()
|
||||
const list = vi.fn()
|
||||
core.subscribe('test.single', single)
|
||||
const unsubscribe = core.subscribe('test.list', list)
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.register({ name: 'test.single' }, Comp)
|
||||
await flushMicrotasks()
|
||||
expect(single).toHaveBeenCalledTimes(1)
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
await flushMicrotasks()
|
||||
expect(list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a mutation from inside a flush re-schedules instead of being lost', async () => {
|
||||
const core = new SlotCore()
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
mountFrame(core)
|
||||
await flushMicrotasks()
|
||||
const seen: number[] = []
|
||||
let reentered = false
|
||||
core.subscribe('test.list', () => {
|
||||
seen.push(core.getVersion('test.list'))
|
||||
if (!reentered) {
|
||||
reentered = true
|
||||
core.register('test.list', Comp, { id: 'reentrant' })
|
||||
core.register({ name: 'test.list', id: 'reentrant' }, Comp)
|
||||
}
|
||||
})
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
await flushMicrotasks()
|
||||
await flushMicrotasks()
|
||||
expect(seen).toHaveLength(2)
|
||||
expect(core.entries('test.list')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('getVersion is 0 for untouched keys and monotonic across redefine', () => {
|
||||
it('getVersion is 0 for untouched keys and monotonic across redeclaration', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.getVersion('test.single')).toBe(0)
|
||||
const dispose = core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
const dispose = mountFrame(core)
|
||||
dispose()
|
||||
const after = core.getVersion('test.single')
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
mountFrame(core)
|
||||
expect(core.getVersion('test.single')).toBeGreaterThan(after)
|
||||
})
|
||||
|
||||
@@ -167,43 +292,14 @@ describe('SlotCore subscription surface', () => {
|
||||
const core = new SlotCore()
|
||||
const keys: string[] = []
|
||||
const off = core.onMutate(key => keys.push(key))
|
||||
core.define('test.single', { kind: 'single', scope: 'root' })
|
||||
core.define('test.list', { kind: 'list', scope: 'root' })
|
||||
core.register('test.list', Comp, { id: 'a' })
|
||||
expect(keys).toEqual(['test.single', 'test.list', 'test.list'])
|
||||
mountFrame(core)
|
||||
// Contribution first, then each declared child key.
|
||||
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
|
||||
keys.length = 0
|
||||
core.register({ name: 'test.list', id: 'a' }, Comp)
|
||||
expect(keys).toEqual(['test.list'])
|
||||
off()
|
||||
core.register('test.list', Comp, { id: 'b' })
|
||||
expect(keys).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SlotOptions typing', () => {
|
||||
it('rejects kind-mismatched options and scope-mismatched inject bindings', () => {
|
||||
// Compile-time negatives only: the body never runs (some rejected shapes
|
||||
// would be legal at runtime, which validates kinds, not props).
|
||||
const typeNegatives = (core: SlotCore) => {
|
||||
// @ts-expect-error single options take no key
|
||||
core.register('test.single', Comp, { key: 'x' })
|
||||
// @ts-expect-error list options require id
|
||||
core.register('test.list', Comp, { order: 1 })
|
||||
// @ts-expect-error keyed options require key
|
||||
core.register('test.keyed', SessionComp, { inject: () => ({}) })
|
||||
// @ts-expect-error component props must match the SlotMap contract
|
||||
core.register('test.single', SessionComp)
|
||||
// @ts-expect-error kind must match the SlotMap declaration
|
||||
core.define('test.single', { kind: 'list', scope: 'root' })
|
||||
const rootInject: SlotOptions<{ kind: 'single'; scope: 'root'; props: { label: string } }> = {
|
||||
// @ts-expect-error root slots bind RootBinding, which has no sessionId
|
||||
inject: (b: RootBinding) => ({ sessionId: b.sessionId }),
|
||||
}
|
||||
return rootInject
|
||||
}
|
||||
expect(typeNegatives).toBeTypeOf('function')
|
||||
|
||||
const sessionInject: SlotOptions<{ kind: 'keyed'; scope: 'session'; props: { label: string } }> = {
|
||||
key: 'k',
|
||||
inject: (b: SessionBinding) => ({ sessionId: b.sessionId }),
|
||||
}
|
||||
expect(sessionInject.key).toBe('k')
|
||||
core.register({ name: 'test.list', id: 'b' }, Comp)
|
||||
expect(keys).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
// Dynamic-key escape hatches and untouched-key behavior of the terminal core.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { narrowSlots, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SlotComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'surface.a': { kind: 'single'; scope: 'root'; props: { label: string } }
|
||||
'surface.b': { kind: 'single'; scope: 'root'; props: { label: string } }
|
||||
'surface.a': { kind: 'single'; scope: 'root' }
|
||||
'surface.b': { kind: 'single'; scope: 'root' }
|
||||
}
|
||||
}
|
||||
|
||||
const Comp: FC<{ label: string }> = () => null
|
||||
const Comp: SlotComponent<object> = () => null
|
||||
|
||||
describe('dynamic-key escape hatch', () => {
|
||||
it('specDynamic reads wide-typed specs for string keys; undefined before define', () => {
|
||||
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.specDynamic('surface.a')).toBeUndefined()
|
||||
core.define('surface.a', { kind: 'single', scope: 'root' })
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.specDynamic('never.defined')).toBeUndefined()
|
||||
expect(core.specDynamic('never.declared')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('spec() narrows by SlotMap key', () => {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.spec('surface.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
|
||||
@@ -27,26 +34,9 @@ describe('dynamic-key escape hatch', () => {
|
||||
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
|
||||
expect(core.getVersion('surface.b')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('narrowSlots', () => {
|
||||
it('returns the same surface narrowed to the subset whitelist', () => {
|
||||
const wide: ScopedSlots<'surface.a' | 'surface.b'> = { renderSlot: () => null }
|
||||
const narrow = narrowSlots<'surface.a', 'surface.a' | 'surface.b'>(wide)
|
||||
expect(narrow).toBe(wide)
|
||||
const rejects = (s: ScopedSlots<'surface.a'>) => {
|
||||
// @ts-expect-error 'surface.b' is outside the narrowed whitelist
|
||||
return () => s.renderSlot('surface.b', {})
|
||||
}
|
||||
expect(rejects(narrow)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration typing', () => {
|
||||
it('keyed/list registrations statically require options (runtime guard retained)', () => {
|
||||
it('isLive is false for entries the core never held', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('surface.a', { kind: 'single', scope: 'root' })
|
||||
// single: options omissible.
|
||||
expect(() => core.register('surface.a', Comp)).not.toThrow()
|
||||
expect(core.isLive({ component: Comp, options: {} })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,140 +1,172 @@
|
||||
// Slot type-chain negative samples (design.md §9 item 2) plus the slots-ring
|
||||
// full-chain positive: register→inject→render composed under the ownership
|
||||
// rule (owner share referenced, injected share locally declared).
|
||||
// Terminal-design compile-time samples (design.md §11 item 2): the four-share
|
||||
// composed register constraint — children spec x SlotMap alignment, renderSlot
|
||||
// key-set containment, store share matching, inject face completeness — plus
|
||||
// the full positive chain. Bodies with @ts-expect-error sites never run.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
OwnerOf, RootBinding, ScopedSlots, SessionBinding, SlotMap, SlotOptions,
|
||||
BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Owner share as the slot-owning package's contract would export it. */
|
||||
interface ChainOwnerShare { sessionId: string }
|
||||
/** Registrant's own injected share (locally declared — ownership rule). */
|
||||
interface ChainInjected { useThing: () => number; actions: { open: () => void } }
|
||||
|
||||
// Only package-unique SlotMap keys are merged here. The standard-kit
|
||||
// interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
|
||||
// the runtime package owns the real members, and in the client aggregate
|
||||
// program a toy merge would collide with them — samples below stay
|
||||
// shape-agnostic about kit member payloads for the same reason.
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'chain.session': { kind: 'single'; scope: 'session'; props: ChainSessionProps; owner: ChainOwnerShare }
|
||||
'chain.root': { kind: 'single'; scope: 'root'; props: ChainRootProps; owner: object }
|
||||
'chain.keyed': { kind: 'keyed'; scope: 'root'; props: ChainRootProps; owner: object }
|
||||
'chain.frame': { kind: 'single'; scope: 'root' }
|
||||
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
|
||||
'chain.conv': { kind: 'single'; scope: 'session' }
|
||||
'chain.tools': { kind: 'keyed'; scope: 'session' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Full props = owner share (referenced) + standard share + own injected. */
|
||||
type ChainSessionProps = ChainOwnerShare & { useSession: unknown } & ChainInjected
|
||||
type ChainRootProps = ChainInjected
|
||||
declare const defineStore: DefineStore
|
||||
|
||||
const SessionComp: FC<ChainSessionProps> = () => null
|
||||
const RootComp: FC<ChainRootProps> = () => null
|
||||
|
||||
describe('type-chain negatives (compile-time; bodies never run)', () => {
|
||||
it('holds the six negative samples as expect-error sites', () => {
|
||||
const negatives = (core: SlotCore, slots: ScopedSlots<'chain.session'>) => {
|
||||
// 1. Owner passing a registrant-injected key through renderSlot.
|
||||
// OwnerOf<'chain.session'> = ChainOwnerShare — useThing is not in it.
|
||||
slots.renderSlot('chain.session', {
|
||||
sessionId: 's1',
|
||||
// @ts-expect-error injected keys are not owner-suppliable
|
||||
useThing: () => 1,
|
||||
})
|
||||
// 2. Inject factory returning a share that mismatches the registrant's
|
||||
// declared own-injected slice (missing `actions`). The I-typed
|
||||
// options form is where the mismatch surfaces (the full composed
|
||||
// register constraint lands with the phase-2 consumer migration).
|
||||
const mismatched: SlotOptions<SlotMap['chain.session'], ChainInjected> = {
|
||||
// @ts-expect-error inject must supply the full registrant share
|
||||
inject: () => ({ useThing: () => 1 }),
|
||||
}
|
||||
void mismatched
|
||||
// 3. renderSlot on a key outside the whitelist.
|
||||
// @ts-expect-error 'chain.root' is not whitelisted on this surface
|
||||
slots.renderSlot('chain.root', {})
|
||||
// 4. keyed registration without options.
|
||||
// @ts-expect-error keyed kind requires options (RegisterArgs)
|
||||
core.register('chain.keyed', RootComp)
|
||||
// 5. Session-slot inject factory typed against RootBinding's surface.
|
||||
const sessionOpts: SlotOptions<{ kind: 'single'; scope: 'session'; props: ChainSessionProps }, ChainInjected> = {
|
||||
// @ts-expect-error session binding has sessionId; RootBinding-only factories don't type-check
|
||||
inject: (b: RootBinding & { notSession: true }) => ({ useThing: () => 1, actions: { open: () => {} } }),
|
||||
}
|
||||
void sessionOpts
|
||||
// 6. Hand-copied owner share drifting from the contract (wrong value type)
|
||||
// — the composed-reference version right below compiles instead.
|
||||
interface DriftedProps { sessionId: number }
|
||||
const Drifted: FC<DriftedProps & ChainInjected> = () => null
|
||||
// @ts-expect-error drifted hand-copy of the owner share fails at register
|
||||
core.register('chain.session', Drifted)
|
||||
return null as ReactNode
|
||||
}
|
||||
expect(negatives).toBeTypeOf('function')
|
||||
/** Factory form (exclusive seat): module-level export, never a handle. */
|
||||
function createPanelStore() {
|
||||
return defineStore({
|
||||
init: () => ({ sidebar: 280, details: 0 }),
|
||||
persist: 'test.panels',
|
||||
actions: {
|
||||
setSidebar: (d, px: number) => { d.sidebar = px },
|
||||
setDetails: (d, px: number) => { d.details = px },
|
||||
},
|
||||
})
|
||||
|
||||
it('full chain (positive dual): composed props register, inject, and render cleanly', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('chain.session', { kind: 'single', scope: 'session' })
|
||||
const dispose = core.register('chain.session', SessionComp, {
|
||||
inject: (b: SessionBinding): ChainInjected => ({
|
||||
useThing: () => b.sessionId.length,
|
||||
actions: { open: () => {} },
|
||||
}),
|
||||
})
|
||||
const entry = core.entries('chain.session')[0]!
|
||||
// Storage erasure boundary: entries() returns the default-I view; the
|
||||
// registrant share is restored after read-back (the budgeted cast).
|
||||
const injected = (entry.options.inject as unknown as (b: SessionBinding) => ChainInjected)(
|
||||
{ sessionId: 's1', session: { useSelector: undefined }, ctx: undefined })
|
||||
expect(injected.useThing()).toBe(2)
|
||||
// Owner share stays reference-typed at the render surface.
|
||||
const ownerShare: OwnerOf<'chain.session'> = { sessionId: 's1' }
|
||||
expect(ownerShare.sessionId).toBe('s1')
|
||||
dispose()
|
||||
expect(core.entries('chain.session')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── children validation layer (B-b, opt-in per entry) ───────────────────────
|
||||
|
||||
/** Delegating owner share: entry declares children, component carries a slots face. */
|
||||
interface DelegOwnerShare { sessionId: string }
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'chain.deleg': { kind: 'single'; scope: 'root'; props: object; owner: DelegOwnerShare; children: 'chain.child-a' | 'chain.child-b' }
|
||||
'chain.child-a': { kind: 'single'; scope: 'root'; props: object; owner: object }
|
||||
'chain.child-b': { kind: 'single'; scope: 'root'; props: object; owner: object }
|
||||
'chain.outside': { kind: 'single'; scope: 'root'; props: object; owner: object }
|
||||
}
|
||||
}
|
||||
|
||||
describe('children validation layer (compile-time; bodies never run)', () => {
|
||||
it('accepts whitelists inside the authorized union, rejects outside keys', () => {
|
||||
const cases = (core: SlotCore) => {
|
||||
// Positive: slots face ⊆ children union (a strict subset is fine).
|
||||
const InUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a'> }> = () => null
|
||||
core.register('chain.deleg', InUnion)
|
||||
// Positive: the full authorized union.
|
||||
const FullUnion: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.child-b'> }> = () => null
|
||||
core.register('chain.deleg', FullUnion)
|
||||
// Positive: no slots face at all — delegation is optional.
|
||||
const NoSlots: FC<DelegOwnerShare> = () => null
|
||||
core.register('chain.deleg', NoSlots)
|
||||
// Negative: a key outside the authorized union collapses the slots constraint.
|
||||
const Outside: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.outside'> }> = () => null
|
||||
// @ts-expect-error slots whitelist must stay inside the entry's children union
|
||||
core.register('chain.deleg', Outside)
|
||||
// Negative: smuggling an extra key alongside authorized ones still fails.
|
||||
const Mixed: FC<DelegOwnerShare & { slots: ScopedSlots<'chain.child-a' | 'chain.outside'> }> = () => null
|
||||
// @ts-expect-error a partially-authorized whitelist is still out of union
|
||||
core.register('chain.deleg', Mixed)
|
||||
// Entries WITHOUT children stay unchecked: any slots face registers
|
||||
// freely (I inferred from the inject factory as usual).
|
||||
const FreeFace: FC<ChainOwnerShare & { useSession: unknown } & ChainInjected & { slots: ScopedSlots<'chain.outside'> }> = () => null
|
||||
core.register('chain.session', FreeFace, {
|
||||
inject: (): ChainInjected & { slots: ScopedSlots<'chain.outside'> } =>
|
||||
({ useThing: () => 1, actions: { open: () => {} }, slots: { renderSlot: () => null } }),
|
||||
})
|
||||
const chatStore = () => defineStore({
|
||||
init: () => ({ selection: null as { id: string } | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, t: { id: string }) => { d.selection = t },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
type ChatHandle = ReturnType<typeof chatStore>
|
||||
|
||||
type FrameProps =
|
||||
& PropsRuntime<'chain.frame'>
|
||||
& PropsRenderSlots<'chain.side' | 'chain.conv'>
|
||||
& PropsStore<ReturnType<typeof createPanelStore>>
|
||||
& { openSettings: () => void }
|
||||
|
||||
type ConvProps =
|
||||
& PropsRuntime<'chain.conv'>
|
||||
& PropsStore<ChatHandle>
|
||||
& { send: (t: string) => void }
|
||||
|
||||
// Component fixtures (never rendered; the register call sites are the test).
|
||||
declare function Frame(props: FrameProps): ReactNode
|
||||
declare function Conv(props: ConvProps): ReactNode
|
||||
declare function Details(props: PropsRuntime<'chain.conv'> & PropsStore<ChatHandle>): ReactNode
|
||||
declare function Tool(props: PropsRuntime<'chain.tools'>): ReactNode
|
||||
declare function Over(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side' | 'chain.conv'>): ReactNode
|
||||
declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.side'>): ReactNode
|
||||
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
|
||||
|
||||
describe('terminal-design type chain', () => {
|
||||
it('holds the positive chain and the compile-time negatives', () => {
|
||||
// Everything below is compile-surface only.
|
||||
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
|
||||
// ── positive chain ─────────────────────────────────────────────
|
||||
// Frame: children + factory store + inject; actions arrive baked.
|
||||
core.register({
|
||||
name: 'chain.frame',
|
||||
children: {
|
||||
'chain.side': { kind: 'single', scope: 'root' },
|
||||
'chain.conv': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: createPanelStore,
|
||||
inject: (actions) => {
|
||||
actions.setSidebar(0)
|
||||
return { openSettings: () => {} }
|
||||
},
|
||||
}, Frame)
|
||||
|
||||
// Conv: shared handle; inject params derive as (sessionId, actions).
|
||||
core.register({
|
||||
name: 'chain.conv',
|
||||
store: chat,
|
||||
inject: (sessionId, actions) => ({
|
||||
send: (text: string) => {
|
||||
const sid: string = sessionId
|
||||
actions.setDraft(text)
|
||||
void sid
|
||||
},
|
||||
}),
|
||||
}, Conv)
|
||||
|
||||
// Pure reader: same handle, no inject.
|
||||
core.register({ name: 'chain.conv', store: chat }, Details)
|
||||
|
||||
// Owner + store shares arrive typed on the component face. Standard-kit
|
||||
// member payloads are the runtime merge's property — not probed here
|
||||
// (the runtime package's own tests cover them).
|
||||
fp.renderSlot('chain.side', { collapsed: false, width: 280 })
|
||||
const draft: string = cp.useStore((s) => s.draft)
|
||||
cp.actions.select({ id: 'm1' })
|
||||
void draft
|
||||
|
||||
// Keyed registration carries key.
|
||||
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
|
||||
|
||||
// ── negatives ──────────────────────────────────────────────────
|
||||
// children spec must match the SlotMap entry.
|
||||
core.register({
|
||||
name: 'chain.frame',
|
||||
// @ts-expect-error chain.conv is session-scoped in SlotMap
|
||||
children: { 'chain.conv': { kind: 'single', scope: 'root' } },
|
||||
}, (() => null) as SlotComponent<never>)
|
||||
|
||||
// renderSlot key set ⊄ children declaration.
|
||||
// @ts-expect-error component renderSlot keys exceed the declaration
|
||||
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Over)
|
||||
|
||||
// renderSlot consumption without any children declaration.
|
||||
// @ts-expect-error no children declaration authorizes rendering
|
||||
core.register({ name: 'chain.frame' }, NoDecl)
|
||||
|
||||
// children declared but component consumes no renderSlot.
|
||||
// @ts-expect-error children declared, component consumes no renderSlot
|
||||
core.register({ name: 'chain.frame', children: { 'chain.side': { kind: 'single', scope: 'root' } } }, Blind)
|
||||
|
||||
// store share mismatch.
|
||||
// @ts-expect-error component's store share doesn't match the declared handle
|
||||
core.register({ name: 'chain.conv', store: chat }, WrongStore)
|
||||
|
||||
// inject face incomplete for the component's business share.
|
||||
// @ts-expect-error inject face missing `send`
|
||||
core.register({ name: 'chain.conv', inject: () => ({ notSend: 1 }) }, Needs)
|
||||
// @ts-expect-error nothing provides `send` (no inject at all)
|
||||
core.register({ name: 'chain.conv' }, Needs)
|
||||
|
||||
// root-scope inject takes no sessionId.
|
||||
core.register({
|
||||
name: 'chain.side',
|
||||
// @ts-expect-error root-scope inject has no sessionId parameter
|
||||
inject: (sessionId: string) => ({ x: sessionId }),
|
||||
}, ((_p) => null) as SlotComponent<PropsRuntime<'chain.side'> & { x: string }>)
|
||||
|
||||
// keyed registration without key.
|
||||
// @ts-expect-error keyed registration requires options.key
|
||||
core.register({ name: 'chain.tools' }, Tool)
|
||||
|
||||
// renderSlot owner share typed at the call site.
|
||||
// @ts-expect-error owner shape mismatch (width missing)
|
||||
fp.renderSlot('chain.side', { collapsed: false })
|
||||
// @ts-expect-error key not in this render share
|
||||
fp.renderSlot('chain.tools', {})
|
||||
|
||||
// baked actions strip the draft parameter.
|
||||
acts.setDraft('x')
|
||||
// @ts-expect-error wrong payload type
|
||||
acts.setDraft(1)
|
||||
}
|
||||
expect(cases).toBeTypeOf('function')
|
||||
expect(samples).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user