/** * React renderer for declarative slots. Per-entry bindings enforce child * authorization, and entry boundaries contain registrant failures. */ import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, type Translate, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, observableHook, projectionHook, useHost, useSessionMaybeProvideInfo, } from './session-provider.tsx' type InjectedProps = Record type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode /** * Per-entry renderSlot bindings. The binding is identity-stable per entry * (memoized components must not resubscribe on unrelated re-renders) and dies * with the entry: a retained closure calling after the entry's disposal hits * the in-ledger check and throws. */ const renderSlotCache = new WeakMap() function boundRenderSlot(host: SlotRendererHost, entry: StoredEntry): RenderSlotBinding { let binding = renderSlotCache.get(entry) if (!binding) { binding = (key, owner, opts) => { if (!host.isLive(entry)) { throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`) } // Plain-JS backstop; typed callers are narrowed to the declared keys. const declared = entry.children?.[key] if (declared === undefined) { throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`) } if (declared.kind === 'chain') { throw new SlotOwnershipError(`slot '${key}' is declared 'chain' — use renderSlotChain`) } return } renderSlotCache.set(entry, binding) } return binding } /** * Per-entry renderSlotChain bindings: identity-stable per entry (same cache * axis as renderSlot — a per-frame dispatch must not rebuild the binding) and * dead with the entry. The chain-kind check is the plain-JS backstop twin of * the declaration check; typed callers are narrowed to chain keys. */ const renderSlotChainCache = new WeakMap() function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): RenderSlotChainBinding { let binding = renderSlotChainCache.get(entry) if (!binding) { binding = (key, owner, opts) => { if (!host.isLive(entry)) { throw new StaleAuthorizationError(`renderSlotChain('${key}') from a disposed registration`) } const declared = entry.children?.[key] if (declared === undefined) { throw new SlotOwnershipError(`slot '${key}' is not declared by this entry's children`) } if (declared.kind !== 'chain') { throw new SlotOwnershipError(`slot '${key}' is declared '${declared.kind}', not 'chain' — use renderSlot`) } return } renderSlotChainCache.set(entry, binding) } return binding } /** * Inject results cache: root entries per entry, session entries per * (entry x provide bundle). WeakMap keys are entry/info objects (both * identity-stable per registration/session scope), so cache lifetime rides * the same axes as the values it memoizes. */ const rootInjectCache = new WeakMap() const sessionInjectCache = new WeakMap>() const sessionMaybeInjectCache = new WeakMap>() function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps { const inject = entry.inject if (!inject) return {} // Declaration-derived positional arguments: sessionId for session scope, // baked actions when a store is declared. const args: unknown[] = [] if (info !== undefined) args.push(info.sessionId) if (actions !== undefined) args.push(actions) return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args)) } /** * Bind an inject face's reserved `hooks` compartment (bare observable * sources, see HooksSources) into `use` selector hooks — the * registrant-private twin of the provide-bundle binding in standardKit. * Runs once per cached inject result; hook identity rides observableHook's * per-source cache. */ function bindInjectHooks(face: InjectedProps): InjectedProps { const sources = face['hooks'] if (sources === undefined) return face const { hooks: _hooks, ...rest } = face const bound: InjectedProps = rest for (const [name, source] of Object.entries(sources as Record>)) { const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` bound[hookName] = observableHook(source) } return bound } function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { let props = rootInjectCache.get(entry) if (!props) { props = runInject(entry, undefined, actions) rootInjectCache.set(entry, props) } return props } function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps { let perInfo = sessionInjectCache.get(entry) if (!perInfo) { perInfo = new WeakMap() sessionInjectCache.set(entry, perInfo) } let props = perInfo.get(info) if (!props) { props = runInject(entry, info, actions) perInfo.set(info, props) } return props } function cachedSessionMaybeInject( entry: StoredEntry, info: SessionMaybeProvideInfo, actions: object | undefined, ): InjectedProps { let perInfo = sessionMaybeInjectCache.get(entry) if (!perInfo) { perInfo = new WeakMap() sessionMaybeInjectCache.set(entry, perInfo) } let props = perInfo.get(info) if (!props) { props = runInject(entry, info, actions) perInfo.set(info, props) } return props } /** * Locale `t` seat bindings, cached per (face, namespace, revision). The * revision is part of the cache key ON PURPOSE: a locale switch mints a NEW * function reference per namespace, so `React.memo` components taking `t` * re-render through ordinary shallow comparison — freshness rides identity, * no extra invalidation channel. Within one revision the reference is stable * (memoized children do not churn on unrelated re-renders). */ const localeSeatCache = new WeakMap>() function localeSeat(face: LocaleFace, ns: string): Translate { let perNs = localeSeatCache.get(face) if (!perNs) { perNs = new Map() localeSeatCache.set(face, perNs) } const revision = face.getSnapshot().revision const cached = perNs.get(ns) if (cached && cached.revision === revision) return cached.t const bound = face.bind(ns) // Fresh wrapper per revision: bind() itself may return a stable reference. const t: Translate = (key, params) => bound(key, params) perNs.set(ns, { revision, t }) return t } const noopSubscribe = (): (() => void) => () => {} const zeroRevision = (): number => 0 /** * Per-face subscribe/getSnapshot closure pair. Cached by face identity: the * face is one global source shared by every outlet, and uSES resubscribes * whenever the subscribe reference changes — fresh closures per render would * churn one unsubscribe/resubscribe pair per outlet per render. */ const localeSubscriptionCache = new WeakMap void) => () => void getRevision: () => number }>() function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } { let cached = localeSubscriptionCache.get(face) if (!cached) { cached = { subscribe: fn => face.subscribe(fn), getRevision: () => face.getSnapshot().revision, } localeSubscriptionCache.set(face, cached) } return cached } /** * Subscribe an outlet to the installed locale face's revision (0 while none * is installed — exactly one uSES call either way, keeping hook order * stable). Every outlet re-renders on a locale switch; entry bodies then * re-derive their `t` seat at the new revision. The face must be installed * before the first render that needs it — a face appearing later has no * notification channel to already-mounted outlets. */ function useLocaleRevision(face: LocaleFace | undefined): number { const subscription = face !== undefined ? localeSubscription(face) : undefined return useSyncExternalStore( subscription?.subscribe ?? noopSubscribe, subscription?.getRevision ?? zeroRevision, ) } /** * Entry-identity React keys for chain boundaries. A chain outlet renders ONE * elected entry through an error boundary; without a key, a boundary that * failed on entry A would survive a re-election and keep a healthy entry B * blacked out. Keying by entry identity remounts the boundary fresh whenever * the election changes (entries are identity-stable per registration, so the * key is stable while the same entry stays elected). */ let nextEntryKey = 0 const entryKeys = new WeakMap() function entryKeyOf(entry: StoredEntry): number { let key = entryKeys.get(entry) if (key === undefined) { key = nextEntryKey++ entryKeys.set(entry, key) } return key } /** * Per-entry isolation: one registrant crashing (component render or inject * factory) must not take down siblings. Assembly errors (missing providers) * rethrow — a miswired shell must fail loud, not degrade into fallbacks. */ class SlotErrorBoundary extends Component< { slotKey: string; children: ReactNode }, { failed: boolean } > { override state = { failed: false } static getDerivedStateFromError(error: unknown): { failed: boolean } { if (error instanceof SlotAssemblyError) throw error return { failed: true } } override componentDidCatch(error: unknown): void { console.error(`slot entry crashed in '${this.props.slotKey}':`, error) } override render(): ReactNode { if (this.state.failed) return
return this.props.children } } /** * Standard-kit synthesis shared by both scope branches: the global * useSessions/useWorkspaces hooks, the per-session provide bundle (every * `hooks` source becomes a `use` selector hook — useSession is the * runtime's own 'session' contribution, no special case — and `props` spread * verbatim), the store pair when declared, the renderSlot binding when * children are declared, and the SessionProvider seat when the children * declare a session-scope slot. Hosts hand out BARE observable sources * (hooks never cross the host contract); every hook is bound HERE, cached * per source (observableHook), so spreading a fresh kit object per render * never churns child subscriptions. */ function standardKit( host: SlotRendererHost, entry: StoredEntry, scope: SlotScope, info: SessionMaybeProvideInfo | undefined, ): { kit: InjectedProps actions: object | undefined } { const kit: InjectedProps = { useSessions: observableHook(host.sessions.list), useWorkspaces: observableHook(host.workspaces.list), } if (scope !== 'root' && info !== undefined) { for (const [name, source] of Object.entries(info.hooks)) { const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` if (scope === 'session-maybe') { kit[hookName] = maybeObservableHook(source) } else { if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) kit[hookName] = observableHook(source) } } Object.assign(kit, info.props) kit['sessionId'] = info.sessionId // The useProjection seat (fifth framework hook): key-addressed cell // reader, bound per provide bundle (cached by info identity). kit['useProjection'] = projectionHook(info) } if (entry.locale !== undefined) { const face = host.locale // Loud assembly failure: locale is immediately-tier infrastructure; a // declared namespace with no installed face is a miswired composition. if (face === undefined) { throw new SlotAssemblyError( `entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`) } kit['t'] = localeSeat(face, entry.locale) } const store = scope === 'session-maybe' && info?.sessionId === undefined ? undefined : host.storeOf(entry, info?.sessionId) if (store !== undefined) { // The instance IS an observable snapshot source (contract getSnapshot/ // subscribe); the useStore hook binds here, cached per instance. kit['useStore'] = observableHook(store) kit['actions'] = store.actions } if (entry.children !== undefined) { kit['renderSlot'] = boundRenderSlot(host, entry) // renderSlotChain rides the same declaration source: only entries whose // children include a chain-kind slot receive the chain dispatch seat. if (Object.values(entry.children).some(spec => spec.kind === 'chain')) { kit['renderSlotChain'] = boundRenderSlotChain(host, entry) } // SessionProvider standard seat: entries declaring a session-scope child // render the session area, so the framework hands them the self-wired // provider (module-level component = stable reference; no value import). if (Object.values(entry.children).some(spec => spec.scope === 'session')) { kit['SessionProvider'] = SessionProvider } } return { kit, actions: store?.actions } } /** * One rendered entry: standard kit + cached inject + owner props (owner * wins). The kit and injected shares are erased at the render boundary — the * register seam already proved the composed contract — so each Entry renders * through a props-widened view of the component (the design-budgeted * composition point, one per scope branch). */ function SessionEntry({ entry, ownerProps, info }: { entry: StoredEntry ownerProps: object info: SessionProvideInfo }) { const host = useHost() const Comp = entry.component as FC const { kit, actions } = standardKit(host, entry, 'session', info) const injected = cachedSessionInject(entry, info, actions) return } function SessionMaybeEntryBody({ entry, ownerProps, info }: { entry: StoredEntry ownerProps: object info: SessionMaybeProvideInfo }) { const host = useHost() const Comp = entry.component as FC const { kit, actions } = standardKit(host, entry, 'session-maybe', info) const injected = cachedSessionMaybeInject(entry, info, actions) return } /** * Session-maybe identity: adoption — the ONLY behavior (there is no * hold-identity-forever mode). An incarnation born session-less ADOPTS the * first session that arrives: identity holds across that one transition * (undefined → first id), so a blank shell's DOM survives the moment a * session appears. From then on the entry behaves exactly like a strict * session entry: switching to a DIFFERENT session remounts (component-local * state must not leak between sessions), and dropping back to no-session * remounts into a fresh blank incarnation, which will adopt again. * Component-local per-session state therefore clears by construction; state * that must SURVIVE a switch belongs in session-bound sources (machine, * store, hooks) — the existing layering rule, now load-bearing. */ function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { const info = useSessionMaybeProvideInfo() // The child key is an incarnation counter, NOT the session id: adoption // must keep the key constant across undefined → first id. Bookkeeping // lives in this stable (unkeyed) wrapper via the render-phase setState // form (React's sanctioned derived-state pattern: setState during render // of the same component re-renders once before children mount, and the // guard conditions make it convergent — StrictMode-safe). const [state, setState] = useState(FIRST_INCARNATION) let { adopted, epoch } = state if (info.sessionId !== undefined && adopted === undefined) { // Adoption: same epoch — no remount. adopted = info.sessionId setState({ adopted, epoch }) } else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) { // Post-adoption session switch: next incarnation, born already adopted. adopted = info.sessionId epoch += 1 setState({ adopted, epoch }) } else if (adopted !== undefined && info.sessionId === undefined) { // Back to no-session: next incarnation, born blank (adopts anew later). adopted = undefined epoch += 1 setState({ adopted, epoch }) } return } /** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */ interface MaybeIncarnation { /** Session this incarnation adopted; undefined while born blank and unadopted. */ readonly adopted: string | undefined /** Incarnation counter — the child key; bumps exactly when an incarnation dies. */ readonly epoch: number } const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 } function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { const host = useHost() const Comp = entry.component as FC const { kit, actions } = standardKit(host, entry, 'root', undefined) const injected = cachedRootInject(entry, actions) return } function StrictSessionEntry({ slotKey, entry, ownerProps }: { slotKey: string entry: StoredEntry ownerProps: object }) { const info = useSessionMaybeProvideInfo() if (info.sessionId === undefined) return null return ( ) } function SlotOutlet({ slotKey, ownerProps, opts }: { slotKey: string ownerProps: object opts?: (RenderOpts & ChainRenderOpts) | undefined }) { const host = useHost() // Version tick drives entries() re-read; the host batches per microtask. useSyncExternalStore( fn => host.subscribe(slotKey, fn), () => host.getVersion(slotKey), ) // Locale revision tick: a locale switch re-renders every outlet, and entry // bodies re-derive their `t` seat at the new revision (fresh identity). useLocaleRevision(host.locale) const sessionInfo = useSessionMaybeProvideInfo() const spec = host.specOf(slotKey) // Undeclared (or no-longer-declared) keys render empty: a declaring entry's // unload returns the slot to the undeclared state while retained elements // may still be mounted — natural empty, not an ownership failure (§9). if (!spec) return null const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) { return <>{opts?.fallback ?? null} } // An absent strict overlay chain follows its ordinary empty-election path, // preserving the Fragment/fallback-wrapper shape across session arrival. const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey) // The boundary must wrap the Entry ELEMENT, not live inside it: inject // factories and kit synthesis run in the Entry body and must land in the // per-entry fallback rather than escaping to the tree above. const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => ( spec.scope === 'session' ? : ( {spec.scope === 'session-maybe' ? : } ) ) if (spec.kind === 'single') { const entry = entries[0] if (!entry) return <>{opts?.fallback ?? null} return guarded(entry) } if (spec.kind === 'keyed') { const entry = entries.find(e => e.options.key === opts?.entryKey) if (!entry) return <>{opts?.fallback ?? null} return guarded(entry) } if (spec.kind === 'chain') { // Entries arrive priority-sorted from the ledger (the core orders at // register, ties keep registration sequence). Selectors are pure // functions of the owner props (register-face contract), so the routing // pass runs per render with zero mount side effects: the first non-null // election renders, decliners never mount. let elected: ReactNode = null for (const entry of entries) { let matched: unknown try { // Chain entries always carry select (SlotCore register validation). matched = (entry.select as (owner: object) => unknown)(ownerProps) } catch (error) { // A throwing selector is a registrant contract breach (select MUST be // pure and total), but it runs before the entry's SlotErrorBoundary // exists — uncontained it would black out the whole owner region. So // it degrades to a decline: the chain and the fallback stay intact, // and the breach is reported like a crashed entry. console.error( `chain selector crashed in '${slotKey}' (${entry.registrant ?? 'unknown registrant'}), treating as declined:`, error) continue } if (matched !== null) { elected = guarded(entry, entryKeyOf(entry), { ...ownerProps, matched }) break } } if (opts?.overlay) { // Overlay chain (ChainRenderOpts.overlay): the fallback stays mounted // through elections — hidden via inline display:none (decisive over any // author CSS), shown via display:contents so the wrapper never affects // the owner's layout. The wrapper's tree position is constant, so React // reconciles instead of remounting and fallback state survives takeover. return ( <>
{opts.fallback ?? null}
{elected} ) } return elected ?? <>{opts?.fallback ?? null} } // list: registration order refined by explicit order, optional id filter. const withListOptions = entries.map(entry => ({ entry, id: entry.options.id, order: entry.options.order ?? 0, })) let list = [...withListOptions].sort((a, b) => a.order - b.order) if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only) if (list.length === 0) return <>{opts?.fallback ?? null} return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))} } /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */ function RootOutlet({ ownerProps }: { ownerProps: object }) { const host = useHost() useSyncExternalStore( fn => host.subscribe('root', fn), () => host.getVersion('root'), ) useLocaleRevision(host.locale) const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( ) } /** * Build the renderer the shell installs into the runtime SlotsService * (ctx.slots.install(createSlotRenderer()) at boot; the service owns the * install/renderSlot seam and the double-install/not-installed throws). * @returns the renderer. */ export function createSlotRenderer(): SlotRenderer { return { renderRoot(host, ownerProps) { return ( ) }, } }