fix(web): keep one composer bar DOM across the no-workspace transition The composer.bar slot moves from session to session-maybe scope: with no current session the entry still mounts, the machine faces (keyboard, stop, command) arrive undefined, and the bar renders its normal DOM inert via the disabled owner prop. DisabledInputBar and its parallel tree are gone, so the textarea node survives the cold-start workspace pick instead of flashing through a remount. A blank session whose workspace was deleted takes the same inert path through owner props. test(web): add the composer DOM-continuity acceptance probe Drives a real dsh web server with headless chromium through the cold-start -> pick-workspace -> type flow and asserts the composer textarea is the same DOM node throughout (a marker property must survive). Rerun prerequisites are in the header comment. docs(web): session-maybe identity is adoption, not hold-forever fix(web): session-maybe entries adopt the first session, then remount like strict entries A session-maybe entry used to keep one React instance across every transition, so component-local state leaked between sessions once the composer bar moved to that scope (PermissionSelect's optimistic pick, the IME composition guard). Identity is now adoption: an incarnation born session-less holds through the arrival of the first session (the blank shell's DOM survives the workspace pick), and afterwards behaves exactly like a strict session entry — a switch or a drop to no-session remounts, clearing local state by construction. The child key is an incarnation counter kept in the stable outlet wrapper via render-phase setState. chore: knip knows the root acceptance probe's playwright dependency scripts/hero-composer-dom-continuity.mjs resolves playwright through apps/web's devDependency tree (createRequire), which knip cannot follow; ignore it at the root workspace.
603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
/**
|
|
* 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<string, unknown>
|
|
|
|
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<StoredEntry, RenderSlotBinding>()
|
|
|
|
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 <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
|
|
}
|
|
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<StoredEntry, RenderSlotChainBinding>()
|
|
|
|
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 <SlotOutlet slotKey={key} ownerProps={owner} opts={opts} />
|
|
}
|
|
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<StoredEntry, InjectedProps>()
|
|
const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionProvideInfo, InjectedProps>>()
|
|
const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<SessionMaybeProvideInfo, InjectedProps>>()
|
|
|
|
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<Name>` 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<string, HostObservable<unknown>>)) {
|
|
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<LocaleFace, Map<string, { revision: number; t: Translate }>>()
|
|
|
|
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<LocaleFace, {
|
|
subscribe: (fn: () => 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<StoredEntry, number>()
|
|
|
|
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 <div data-slot-error={this.props.slotKey} />
|
|
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<Name>` 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<InjectedProps>
|
|
const { kit, actions } = standardKit(host, entry, 'session', info)
|
|
const injected = cachedSessionInject(entry, info, actions)
|
|
return <Comp {...kit} {...injected} {...ownerProps} />
|
|
}
|
|
|
|
function SessionMaybeEntryBody({ entry, ownerProps, info }: {
|
|
entry: StoredEntry
|
|
ownerProps: object
|
|
info: SessionMaybeProvideInfo
|
|
}) {
|
|
const host = useHost()
|
|
const Comp = entry.component as FC<InjectedProps>
|
|
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
|
|
const injected = cachedSessionMaybeInject(entry, info, actions)
|
|
return <Comp {...kit} {...injected} {...ownerProps} />
|
|
}
|
|
|
|
/**
|
|
* 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<MaybeIncarnation>(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 <SessionMaybeEntryBody key={epoch} entry={entry} ownerProps={ownerProps} info={info} />
|
|
}
|
|
|
|
/** 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<InjectedProps>
|
|
const { kit, actions } = standardKit(host, entry, 'root', undefined)
|
|
const injected = cachedRootInject(entry, actions)
|
|
return <Comp {...kit} {...injected} {...ownerProps} />
|
|
}
|
|
|
|
function StrictSessionEntry({ slotKey, entry, ownerProps }: {
|
|
slotKey: string
|
|
entry: StoredEntry
|
|
ownerProps: object
|
|
}) {
|
|
const info = useSessionMaybeProvideInfo()
|
|
if (info.sessionId === undefined) return null
|
|
return (
|
|
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId}>
|
|
<SessionEntry entry={entry} ownerProps={ownerProps} info={info as SessionProvideInfo} />
|
|
</SlotErrorBoundary>
|
|
)
|
|
}
|
|
|
|
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'
|
|
? <StrictSessionEntry slotKey={slotKey} entry={entry} ownerProps={owner} key={key} />
|
|
: (
|
|
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
|
{spec.scope === 'session-maybe'
|
|
? <SessionMaybeEntry entry={entry} ownerProps={owner} />
|
|
: <RootEntry entry={entry} ownerProps={owner} />}
|
|
</SlotErrorBoundary>
|
|
)
|
|
)
|
|
|
|
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 (
|
|
<>
|
|
<div
|
|
data-chain-overlay-fallback={slotKey}
|
|
style={{ display: elected === null ? 'contents' : 'none' }}
|
|
>
|
|
{opts.fallback ?? null}
|
|
</div>
|
|
{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 (
|
|
<SlotErrorBoundary slotKey="root">
|
|
<RootEntry entry={entry} ownerProps={ownerProps} />
|
|
</SlotErrorBoundary>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<HostContext.Provider value={host}>
|
|
<SessionMaybeProvider>
|
|
<RootOutlet ownerProps={ownerProps} />
|
|
</SessionMaybeProvider>
|
|
</HostContext.Provider>
|
|
)
|
|
},
|
|
}
|
|
}
|