mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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,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
|
||||
|
||||
116
packages/client/ui-slots/src/renderer.ts
Normal file
116
packages/client/ui-slots/src/renderer.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
|
||||
* web-react's machinery implements, the host surface the runtime SlotsService
|
||||
* presents to the installed renderer, and the render-path authorization
|
||||
* errors. Pure types plus two error classes — this package stays React-free
|
||||
* at runtime (React types only).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
|
||||
|
||||
/** Minimal observable surface for host-provided standard-kit data sources. */
|
||||
export interface HostObservable<T> {
|
||||
getSnapshot(): T
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-erased store instance face at the render seam (the typed twin is
|
||||
* {@link StoreInstance}): selector hook plus draft-stripped action callbacks.
|
||||
* Typing lands at the component seam via {@link PropsStore}.
|
||||
*/
|
||||
export interface StoreInstanceLike {
|
||||
readonly useSelector: unknown
|
||||
readonly actions: Record<string, (...params: never[]) => void>
|
||||
}
|
||||
|
||||
/** Session standard kit resolved per session id (identity-stable per session scope; a recreated scope yields a new cell). */
|
||||
export interface SessionCell {
|
||||
sessionId: string
|
||||
/** Bound conversation-snapshot selector hook (wide here; runtime narrows at its export seam). */
|
||||
useSession: unknown
|
||||
}
|
||||
|
||||
/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */
|
||||
export interface RenderOpts {
|
||||
entryKey?: string
|
||||
only?: string
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
/** Host surface the runtime SlotsService presents to the installed renderer. */
|
||||
export interface SlotRendererHost {
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
* @param key - slot key.
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(key: string, fn: () => void): () => void
|
||||
/**
|
||||
* Monotonic version for uSES pairing.
|
||||
* @param key - slot key.
|
||||
* @returns current version.
|
||||
*/
|
||||
getVersion(key: string): number
|
||||
/**
|
||||
* Snapshot the registered entries for a key (stable reference between mutations).
|
||||
* @param key - slot key.
|
||||
* @returns entries in registration (list: order) sequence.
|
||||
*/
|
||||
entriesOf(key: string): readonly StoredEntry[]
|
||||
/**
|
||||
* Declared runtime spec from the declarations ledger.
|
||||
* @param key - slot key.
|
||||
* @returns the spec, or undefined while the key is undeclared (outlets render empty).
|
||||
*/
|
||||
specOf(key: string): SlotSpec<SlotEntryDef> | undefined
|
||||
/**
|
||||
* Stale-authorization check: whether the entry is still in the ledger.
|
||||
* @param entry - a previously rendered entry.
|
||||
* @returns false once the entry's registration was disposed.
|
||||
*/
|
||||
isLive(entry: StoredEntry): boolean
|
||||
/**
|
||||
* Resolve (create or return cached) the store instance for an entry's
|
||||
* declared handle under a scope key; lifecycle rides the ledger axis.
|
||||
* @param entry - entry whose declaration carries the handle.
|
||||
* @param scopeKey - session id for session-scope slots, undefined for root scope.
|
||||
* @returns the instance, or undefined when the entry declares no store.
|
||||
*/
|
||||
storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined
|
||||
/** Session-side standard-kit sources. */
|
||||
sessions: {
|
||||
/** Session list source backing the useSessions standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
|
||||
current: HostObservable<string | undefined>
|
||||
/**
|
||||
* Resolve the session standard kit.
|
||||
* @param id - session id.
|
||||
* @returns the cell, or undefined for an unknown session (provider falls to empty).
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
|
||||
export interface SlotRenderer {
|
||||
/**
|
||||
* Render the root slot tree over the host surface (the only ctx-level entry).
|
||||
* @param host - the installing service's host surface.
|
||||
* @param ownerProps - owner props from the shell's renderSlot('root', ...) call.
|
||||
* @returns the rendered tree.
|
||||
*/
|
||||
renderRoot(host: SlotRendererHost, ownerProps: object): ReactNode
|
||||
}
|
||||
|
||||
/** Thrown when a retained renderSlot binding is invoked after its declaring entry was disposed. */
|
||||
export class StaleAuthorizationError extends Error {}
|
||||
|
||||
/**
|
||||
* Thrown when a renderSlot binding is invoked for a key outside its entry's
|
||||
* children declaration (plain-JS backstop; typed callers are narrowed
|
||||
* statically).
|
||||
*/
|
||||
export class SlotOwnershipError extends Error {}
|
||||
137
packages/client/ui-slots/src/store.ts
Normal file
137
packages/client/ui-slots/src/store.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Store-seat type family (slot terminal design §4): a registrant declares its
|
||||
* shared/exclusive business store as data — schema (`init`), optional
|
||||
* persistence key, and the complete write set (`actions`) — and the framework
|
||||
* owns instance lifecycle (scope derives from the mounting entry's slot).
|
||||
* ui-slots ships the contract types only; the engine-backed `defineStore`
|
||||
* value lives in web-react (the snapshot-store engine's home) and must
|
||||
* satisfy {@link DefineStore}.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Typed selector hook over a snapshot source. Canonical shape for the whole
|
||||
* slot system (web-react's engine hook is structurally identical; the
|
||||
* framework is the only party that ever constructs one).
|
||||
*/
|
||||
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
|
||||
|
||||
/**
|
||||
* Action declaration table: pure immer-draft transforms over the store state,
|
||||
* declared as the store's complete write set (the audit face — components can
|
||||
* only write through these).
|
||||
*/
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
|
||||
* any[] (not unknown[]): each action carries its own parameter list, and
|
||||
* unknown[] would reject every concrete signature under strict parameter
|
||||
* contravariance. Params are re-inferred per action by BakedActions. */
|
||||
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
|
||||
|
||||
/**
|
||||
* Draft-stripped callback form of an actions table: what components
|
||||
* (`props.actions`) and inject factories receive — the framework bakes the
|
||||
* draft parameter away by binding each action to the resolved instance.
|
||||
*/
|
||||
export type BakedActions<T, A extends ActionsDecl<T>> = {
|
||||
[K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
|
||||
}
|
||||
|
||||
/**
|
||||
* Store declaration spec: initial-state factory (a lambda so every instance
|
||||
* gets a fresh state), optional persistence key (mechanical, framework-run),
|
||||
* and the actions write set.
|
||||
*/
|
||||
export interface StoreSpec<T, A extends ActionsDecl<T>> {
|
||||
/** Initial-state factory; called once per framework-created instance. */
|
||||
init: () => T
|
||||
/** Opt-in persistence key (storage mechanics belong to the engine). */
|
||||
persist?: string
|
||||
/** Complete write set: pure draft transforms. */
|
||||
actions: A
|
||||
}
|
||||
|
||||
/**
|
||||
* Live engine instance: the create() product consumed by the render machinery
|
||||
* and by component tests (fed straight into props as useStore/actions).
|
||||
* Production components and render paths never call create() themselves —
|
||||
* instance lifecycle is the framework's.
|
||||
*/
|
||||
export interface StoreInstance<T, A extends ActionsDecl<T>> {
|
||||
/** Selector hook bound to this instance (delivered to components as `useStore`). */
|
||||
readonly useSelector: SnapshotSelectorHook<T>
|
||||
/** Baked write callbacks (delivered to components as `actions`). */
|
||||
readonly actions: BakedActions<T, A>
|
||||
/** Current state snapshot (test assertions; machinery). */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Subscribe to state changes.
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
/**
|
||||
* Drop this instance's persisted value (no-op for non-persist specs). The
|
||||
* framework calls it when the owning scope dies for good — a pruned session
|
||||
* must not leave orphaned storage keys behind.
|
||||
*/
|
||||
clearPersisted(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Store handle: spec + state/actions types + shared identity + instance
|
||||
* factory in one value. Handles are constructed in apply world (shared across
|
||||
* registrations of one plugin) or by the framework from a registrant's
|
||||
* factory (exclusive). Never export a handle at module level — module-cache
|
||||
* identity is a disguised singleton across plugin reloads.
|
||||
*/
|
||||
export interface StoreHandle<T, A extends ActionsDecl<T>> {
|
||||
/** The inert declaration this handle was defined from. */
|
||||
readonly spec: StoreSpec<T, A>
|
||||
/**
|
||||
* Create a live engine instance (framework machinery and tests only).
|
||||
* @param scopeKey - session id for session-scope instances; suffixes the
|
||||
* persist key so per-session instances persist independently (root-scope
|
||||
* instances omit it).
|
||||
* @returns a fresh instance seeded from `spec.init()`.
|
||||
*/
|
||||
create(scopeKey?: string): StoreInstance<T, A>
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusive-store registration form: the registrant passes the factory itself
|
||||
* and the framework calls it per entry x scope (no shared identity exists).
|
||||
*/
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
|
||||
* erased position accepting every StoreHandle instantiation; T/A are
|
||||
* recovered per use site by conditional inference (HandleOf/BoundActions/
|
||||
* PropsStore). */
|
||||
export type StoreFactory = () => StoreHandle<any, any>
|
||||
|
||||
/** The register `store` option position: a shared handle or an exclusive factory. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
|
||||
export type StoreDecl = StoreHandle<any, any> | StoreFactory
|
||||
|
||||
/** Normalize a store declaration to its handle type (factories yield their return). */
|
||||
export type HandleOf<H> = H extends () => infer R ? R : H
|
||||
|
||||
/**
|
||||
* Handle-keyed baked actions: the `actions` parameter of an inject factory
|
||||
* whose registration declared a store — the same baked callback set the
|
||||
* component receives via {@link PropsStore}.
|
||||
*/
|
||||
export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
|
||||
|
||||
/**
|
||||
* The store props share, derived from the declared handle: a typed selector
|
||||
* hook plus the baked write set. Components never see the instance itself
|
||||
* (no update/set — reads via useStore, writes via the declared actions only).
|
||||
*/
|
||||
export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
|
||||
? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
|
||||
: object
|
||||
|
||||
/**
|
||||
* The defineStore contract (implementation lives in web-react, bound to the
|
||||
* snapshot-store engine): spec in, handle out, with T inferred from `init`
|
||||
* and the actions table constrained by T.
|
||||
*/
|
||||
export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>
|
||||
Reference in New Issue
Block a user