/** * 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 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. */ /* oxlint-disable typescript/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 { ReactNode } from 'react' import type { HostObservable } from './renderer.ts' import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts' export * from './store.ts' export * from './renderer.ts' export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} /** * Locale namespace table. Dictionary owners extend via declaration merging * (exactly like {@link SlotMap}, and declared in this entry module for the * same lexical-merge reason): the key is the namespace string, the value is * the union of its dictionary keys. Register sites declare one of these * namespaces (`locale:`), which puts the typed `t` standard seat on the * component props. */ export interface LocaleNamespaceMap {} /** * Translate a dictionary key with optional `{name}` template params. * `K` narrows the accepted keys to the owning namespace's dictionary union * (plus the shared common vocabulary where composed). */ export type Translate = (key: K, params?: Record) => string /** * The shared `common` vocabulary keys as merged by the locale plugin; * resolves to `never` in programs without the merge (this package's tests), * keeping the union collapse harmless. */ export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never /** * Key domain of a namespace-bound translate: the namespace's own dictionary * union plus the shared common vocabulary (the lookup chain consults common * after the namespace misses). */ export type LocaleKeysOf = (LocaleNamespaceMap[N] & string) | CommonKeyOf /** * Namespace-addressed translate — the developer-facing alias over * {@link Translate}: `TranslateNS<'model'>` is the translate function of the * `model` namespace (key domain = its dictionary union plus the shared * common vocabulary), the exact type of the framework-injected `t` seat and * of the locale service's typed `bind`. */ export type TranslateNS = Translate> /** * Dictionary shape for a declared namespace: exactly the keys the namespace * merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed * registration site is a compile error. */ export type LocaleDictOf = Record /** * Locale share of the composed component props: the framework-injected `t` * seat, present exactly on entries whose registration declares `locale:`. */ export type PropsLocale = N extends keyof LocaleNamespaceMap & string ? { /** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */ t: TranslateNS } : object /** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */ export type SlotKind = 'single' | 'list' | 'keyed' | 'chain' /** Slot data context: global, current-session-optional, or strict session-bound. */ export type SlotScope = 'root' | 'session-maybe' | 'session' /** * 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 owner?: object } /** * 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` in {@link ChildrenDecl}), so type and value * are declared at one point and validate each other. */ export interface SlotSpec { kind: E['kind']; scope: E['scope'] } /** * 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 ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec } /** Owner-supplied props share for a slot key ({} for entries declaring no `owner`). */ export type OwnerOf = SlotMap[K] extends { owner: infer O extends object } ? O : object /** Scope axis of a slot key's SlotMap entry. */ export type ScopeOf = 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 current-session-optional slots. Its * hooks stay callable while no session is selected and return `undefined` * until one becomes current; concrete members merge in at runtime packages. */ export interface SessionMaybeStandardProps {} /** * Framework standard kit delivered to EVERY slot component (the global seat). * Declared empty here; the runtime package merges the global object-layer * selector hooks that shared page composition consumes. */ 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 = OwnerOf & (ScopeOf extends 'session' ? SessionStandardProps : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps : object) & GlobalStandardProps /** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */ export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode } /** renderSlotChain dispatch options. */ export interface ChainRenderOpts { /** The owner's fallback body, rendered when every entry's selector declines. */ fallback?: ReactNode /** * Keep the fallback permanently mounted: an election hides it (wrapped, * display:none) instead of unmounting it, and the all-decline case shows it * as-is — fallback-held state (composer drafts, DOM state) survives a * takeover. Chain kind only. Sole consumer today: the * 'conversation.composer' chain. */ overlay?: boolean } /** * Chain-entry selector: the routing decision of one chain contribution. * Runs at render time in chain order (ascending `priority`, default 0, lower * tries first; ties keep registration = assembly order); the first non-null * return elects its entry * and becomes the component's `matched` prop; `null` passes to the next * entry; all-null falls to the owner's {@link ChainRenderOpts} fallback. * MUST be pure — a function of the owner props only, no external mutable * reads, no side effects (the decline decision lives here, never in a * mounted component probing its own props). */ export type ChainSelect = (owner: O) => M | null /** Keys of a slot-key union whose SlotMap entry is chain-kind (renderSlotChain's dispatch domain). */ export type ChainKeysOf = S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never /** * Chain matched share: a chain-slot component receives its selector's * non-null result as the framework-injected `matched` prop; other kinds add * nothing to the composed constraint. */ export type MatchedShare = E['kind'] extends 'chain' ? { matched: M } : object /** * Conversation-session selector hook alias for props contracts. Wide by * default at this dependency-inverted layer; the runtime narrows at its * export seam (`UseSession`). */ export type UseSession = SnapshotSelectorHook /** Props of the standard-kit SessionProvider seat (render-prop form). */ export interface SessionAreaProps { /** No-session body (also covers a current id whose session cannot be resolved). */ empty?: (() => ReactNode) | undefined /** Session body; the framework remounts it per session (key=sessionId). */ children: (sessionId: SessionIdOf) => ReactNode } /** * Framework-wired session area component. It subscribes to runtime-owned * session selection and is injected into entries that declare session-scoped * children; business code does not import it directly. */ export type SessionProviderComponent = (props: SessionAreaProps) => 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 = { /** * Render a declared non-chain child slot (chain keys dispatch through * `renderSlotChain` — their routing lives in entry selectors). * @param key - declared child key. * @param owner - owner props share for that key (decided at the render site). * @param opts - kind dispatch options. * @returns rendered node(s). */ renderSlot: >>(key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode readonly __renders?: ((key: S) => void) | undefined } & ([ChainKeysOf] extends [never] ? object : { /** * Render a declared chain child slot: entry selectors run in chain order * over `owner`; the first non-null match renders its component with the * selector result injected as `matched`; all-null renders `opts.fallback`. * @param key - declared chain child key. * @param owner - owner props share (the selectors' routing input). * @param opts - fallback body for the all-null case. * @returns rendered node(s). */ renderSlotChain: >(key: K, owner: OwnerOf, opts?: ChainRenderOpts) => ReactNode }) & ('session' extends ScopeOf // The SessionProvider seat rides the same source as renderSlot: declaring // a session-scope child is what makes a session area exist, so the seat // derives from the children key set's scopes (renderer injects the value). ? { SessionProvider: SessionProviderComponent } : object) /** * 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

= (props: P) => ReactNode /** * Registrant hooks compartment: bare observable sources (getSnapshot + * subscribe pairs) supplied under the reserved `hooks` key of an inject * face. The registrant-private twin of the `sessions.provide` hooks * compartment: the renderer binds each source into a `use` selector * hook, so the sources never reach the component and plugin-private reactive * facts ride the same subscription machinery as the standard kit instead of * hand-rolled component subscriptions. */ export type HooksSources = Record> /** * Selector-hook share synthesized from a hooks compartment: each source * `name` becomes a `use` selector hook over its snapshot type. */ export type PropsHooks = { [N in keyof HS & string as `use${Capitalize}`]: SnapshotSelectorHook ? T : never> } /** * The component-side view of an inject face: the reserved `hooks` * compartment (when declared) arrives as bound `use` selector hooks; * every other member passes through verbatim. */ export type InjectFace = I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I /** * The composed component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + * the registrant's injected business face (its hooks compartment bound, see * {@link InjectFace}) + the locale `t` seat (declared namespace, see * {@link PropsLocale}). 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, M = never, N = undefined, > = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: * strict session slots receive a definite framework-resolved `sessionId`; * session-maybe slots receive the current id or `undefined`; a declared store * appends the baked `actions` (the same callbacks the component receives). * Business data access happens through the apply closure's ctx — no binding * object parameter exists. */ export type InjectParams = ScopeOf extends 'session' ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions>] : [sessionId: SessionIdOf]) : ScopeOf extends 'session-maybe' ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf | undefined, actions: BoundActions> | undefined] : [sessionId: SessionIdOf | undefined]) : ([H] extends [StoreDecl] ? [actions: BoundActions>] : []) /** * A list-entry display label: a plain string, or a thunk re-evaluated per * read so registration-time text (nav rows, tabs) follows the active locale * without re-registration. Owners resolve through {@link resolveSlotLabel}. */ export type SlotLabel = string | (() => string) /** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ export type KindOptions = E['kind'] extends 'keyed' ? { key: string } : E['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } : E['kind'] extends 'chain' ? { /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ select: ChainSelect /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ priority?: number } : object /** * Compile-time presence check: an entry declaring children MUST consume * `renderSlot` (or `renderSlotChain` when its only children are chain slots) * — declaring is claiming; an entry that does not render its children should * not declare them. Evaluates to an unsatisfiable intersection member naming * the declared keys when violated. */ type RendersCheck = [keyof D & keyof SlotMap & string] extends [never] ? unknown : C extends (props: infer P) => ReactNode ? ('renderSlot' extends keyof P ? unknown : 'renderSlotChain' extends keyof P ? unknown : { 'children declared but the component consumes no renderSlot': keyof D & keyof SlotMap & string }) : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ type BaseOptions = { /** 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 /** * Dictionary namespace of this entry's copy. Declaring it puts the * framework-synthesized `t` seat (typed to the namespace's dictionary * union) on the component props; rendering requires an installed locale * face — fails loud otherwise. */ locale?: N /** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */ registrant?: string } & KindOptions /** * 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?: SlotLabel; priority?: number } /** Chain routing selector (type-erased like `inject`; present exactly on chain-slot entries). */ select?: ((owner: never) => unknown) | undefined /** Registrant business face; positional params derive from the declaration (sessionId?, actions?). */ inject?: ((...args: never[]) => Record) | undefined /** Child-slot declaration table (declaration + authorization + runtime spec in one). */ children?: Readonly>> | undefined /** Declared store seat (instance resolution and lifecycle live with the host machinery). */ store?: StoreDecl | undefined /** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */ locale?: string | undefined /** Diagnostics label of who registered. */ registrant?: string | undefined } /** * Resolve a possibly-thunked list label at read time (thunks follow the * active locale; owners projecting ledger rows call this instead of reading * `options.label` raw). * @param label - the stored label. * @returns the display string, or undefined when the entry declared none. */ export function resolveSlotLabel(label: SlotLabel | undefined): string | undefined { return typeof label === 'function' ? label() : label } /** * 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?: SlotLabel | undefined select?: ((owner: never) => unknown) | undefined priority?: number | undefined children?: Record> | undefined store?: StoreDecl | undefined locale?: string | undefined /* oxlint-disable-next-line typescript/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) | undefined registrant?: string | undefined } /** * Per-key registry record. Created on first touch; never removed (version * stays monotonic across redeclarations). */ interface SlotRecord { spec: SlotSpec | undefined /** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */ declaredBy: string | undefined entries: readonly StoredEntry[] version: number listeners: Set<() => void> } const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) /** * 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 * fire); {@link SlotCore.subscribe} notifications batch per microtask, so N * same-tick mutations produce one notification per touched key. */ export class SlotCore { private records = new Map() 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() // 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() 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)' } /** * Contribute a component to a declared slot and (optionally) declare child * slots, a store seat, and the registrant's business face. * * 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; chain — missing `select` throws (the * selector is the entry's routing seat, see {@link ChainSelect}). * * Lifecycle: the disposer removes the contribution AND collapses every * declared child slot (child entries clear recursively; their stale * 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). */ /* jscpd:ignore-start -- the two register overloads are deliberately * parallel declarations differing only in the inject share; folding them * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( options: BaseOptions & { inject?: undefined }, component: C & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** * Inject-bearing overload: identical semantics to the overload above, plus * the registrant's business face — `I` is inferred from the inject * factory's return and joins the component's composed-props constraint * (factory parameters derive from the declaration, {@link InjectParams}). * @param options - registration options plus the `inject` business-face factory. * @param component - component honoring the four-share composed props * contract including the inject share `I`. * @returns disposer removing the registration and its declarations. */ register< K extends keyof SlotMap & string, I extends object, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void /* jscpd:ignore-end */ 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 case 'chain': if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`) break } if (options.children) { for (const childKey of Object.keys(options.children)) { 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.priority !== undefined ? { priority: options.priority } : {}), }, ...(options.select !== undefined ? { select: options.select } : {}), ...(options.inject !== undefined ? { inject: options.inject } : {}), ...(options.children !== undefined ? { children: options.children } : {}), ...(options.store !== undefined ? { store: options.store } : {}), ...(options.locale !== undefined ? { locale: options.locale } : {}), ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] // Stable sorts: ascending, ties keep registration sequence (list rides // `order`, chain rides `priority` — lower priority tries first). if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0)) if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) rec.entries = next this.markDirty(options.name, rec) if (options.children) { 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.entries.includes(entry)) return rec.entries = rec.entries.filter(e => e !== entry) this.markDirty(options.name, rec) this.releaseEntry(entry) } } /** * 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. */ 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) 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(key: string): readonly StoredEntry[] { return this.records.get(key)?.entries ?? NO_ENTRIES } /** * Look up a slot's declared spec, narrowed by the SlotMap key. * @param key - SlotMap key. * @returns the spec, or undefined while undeclared. */ spec(key: K): SlotSpec | undefined { return this.records.get(key)?.spec as SlotSpec | undefined } /** * Dynamic-key escape hatch for spec lookup — renderers resolving keys they * 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 while undeclared. */ specDynamic(key: string): SlotSpec | undefined { return this.records.get(key)?.spec } /** * Subscribe to registration changes for a key (microtask-batched). * Subscribing ahead of declaration is allowed; the declaration notifies. * @param key - slot key. * @param fn - change callback. * @returns unsubscribe. */ subscribe(key: string, fn: () => void): () => void { const rec = this.record(key) rec.listeners.add(fn) return () => { rec.listeners.delete(fn) } } /** * Monotonic version for a key, bumped synchronously per mutation so a * uSES getSnapshot read is never stale when its batched notification lands. * @param key - slot key. * @returns current version (0 for untouched keys). */ getVersion(key: string): number { return this.records.get(key)?.version ?? 0 } /** * Hook every mutation (the runtime Service wrapper bridges this to ctx.emit). * Fires synchronously per mutation, unbatched — event semantics need one * emission per change. * @param fn - called with the mutated key. * @returns unsubscribe. */ onMutate(fn: (key: string) => void): () => void { this.mutateListeners.add(fn) 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, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() } this.records.set(key, rec) } return rec } private markDirty(key: string, rec: SlotRecord): void { rec.version += 1 for (const fn of [...this.mutateListeners]) fn(key) this.dirty.add(rec) if (!this.flushScheduled) { this.flushScheduled = true queueMicrotask(() => { this.flush() }) } } private flush(): void { // Reset before iterating so a mutation from inside a listener re-schedules. this.flushScheduled = false const dirty = [...this.dirty] this.dirty.clear() for (const rec of dirty) { for (const fn of [...rec.listeners]) fn() } } }