|
|
|
|
@@ -283,12 +283,14 @@ function useLocaleRevision(face: LocaleFace | undefined): number {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
* Entry-identity React keys for entry boundaries. An outlet renders one
|
|
|
|
|
* winner per position (single/keyed/list cell head, chain election) through
|
|
|
|
|
* an error boundary; without a key, a boundary that failed on entry A would
|
|
|
|
|
* survive a winner change (re-election, shadowing fallback after an
|
|
|
|
|
* abdication, HMR re-registration) and keep a healthy entry B blacked out.
|
|
|
|
|
* Keying by entry identity remounts the boundary fresh whenever the winner
|
|
|
|
|
* changes (entries are identity-stable per registration, so the key is
|
|
|
|
|
* stable while the same entry stays the winner).
|
|
|
|
|
*/
|
|
|
|
|
let nextEntryKey = 0
|
|
|
|
|
const entryKeys = new WeakMap<StoredEntry, number>()
|
|
|
|
|
@@ -306,9 +308,14 @@ function entryKeyOf(entry: StoredEntry): number {
|
|
|
|
|
* 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.
|
|
|
|
|
* Every catch reports through `onEntryError` (the ledger's supervision
|
|
|
|
|
* seam); for shadowing kinds the report abdicates the entry, the outlet
|
|
|
|
|
* re-renders onto the cell's next survivor, and this boundary's crash face
|
|
|
|
|
* only shows until that re-render lands (permanently once the cell is dry —
|
|
|
|
|
* the outlet then owns the crash face).
|
|
|
|
|
*/
|
|
|
|
|
class SlotErrorBoundary extends Component<
|
|
|
|
|
{ slotKey: string; children: ReactNode }, { failed: boolean }
|
|
|
|
|
{ slotKey: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
|
|
|
|
|
> {
|
|
|
|
|
override state = { failed: false }
|
|
|
|
|
static getDerivedStateFromError(error: unknown): { failed: boolean } {
|
|
|
|
|
@@ -317,6 +324,7 @@ class SlotErrorBoundary extends Component<
|
|
|
|
|
}
|
|
|
|
|
override componentDidCatch(error: unknown): void {
|
|
|
|
|
console.error(`slot entry crashed in '${this.props.slotKey}':`, error)
|
|
|
|
|
this.props.onEntryError(error)
|
|
|
|
|
}
|
|
|
|
|
override render(): ReactNode {
|
|
|
|
|
if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
|
|
|
|
|
@@ -607,18 +615,21 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
|
|
|
|
|
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext }: {
|
|
|
|
|
function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: {
|
|
|
|
|
slotKey: string
|
|
|
|
|
entry: StoredEntry
|
|
|
|
|
ownerProps: object
|
|
|
|
|
slotInjected: BoundSlotInject
|
|
|
|
|
hookContext: unknown
|
|
|
|
|
hasHookContext: boolean
|
|
|
|
|
onEntryError: (error: unknown) => void
|
|
|
|
|
}) {
|
|
|
|
|
const info = useSessionMaybeProvideInfo()
|
|
|
|
|
if (info.sessionId === undefined) return null
|
|
|
|
|
// Per-session remount rides this key; per-entry remount rides the outer
|
|
|
|
|
// element's entry-identity key (the outlet's guarded() call).
|
|
|
|
|
return (
|
|
|
|
|
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId}>
|
|
|
|
|
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId} onEntryError={onEntryError}>
|
|
|
|
|
<SessionEntry
|
|
|
|
|
entry={entry}
|
|
|
|
|
ownerProps={ownerProps}
|
|
|
|
|
@@ -632,6 +643,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Anchor style shared by every outlet wrapper: `display:contents` keeps the
|
|
|
|
|
* wrapper out of layout (grid/flex parents see the slot's own children), so
|
|
|
|
|
* the anchor is purely addressable surface. Module-level constant — a stable
|
|
|
|
|
* reference so the wrapper never diffs its style prop.
|
|
|
|
|
*/
|
|
|
|
|
const ANCHOR_STYLE = { display: 'contents' } as const
|
|
|
|
|
|
|
|
|
|
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
|
|
slotKey: string
|
|
|
|
|
ownerProps: object
|
|
|
|
|
@@ -647,6 +666,27 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
|
|
// bodies re-derive their `t` seat at the new revision (fresh identity).
|
|
|
|
|
useLocaleRevision(host.locale)
|
|
|
|
|
const sessionInfo = useSessionMaybeProvideInfo()
|
|
|
|
|
// Anchor contract: every slot render site exposes a stable
|
|
|
|
|
// `[data-slot="<key>"]` wrapper — the addressable seam dynamic styles
|
|
|
|
|
// target — and `display:contents` keeps it layout-neutral. The wrapper
|
|
|
|
|
// rides the outlet, not the dispatch outcome: fallback, crash-face, and
|
|
|
|
|
// undeclared-empty states all render inside it, so the anchor's presence
|
|
|
|
|
// never flickers with registration churn.
|
|
|
|
|
return (
|
|
|
|
|
<div data-slot={slotKey} style={ANCHOR_STYLE}>
|
|
|
|
|
{renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Kind dispatch behind the outlet anchor (single/keyed/list/chain, fallbacks, crash faces). */
|
|
|
|
|
function renderOutletContent(
|
|
|
|
|
host: SlotRendererHost,
|
|
|
|
|
slotKey: string,
|
|
|
|
|
ownerProps: object,
|
|
|
|
|
opts: (RenderOpts & ChainRenderOpts) | undefined,
|
|
|
|
|
sessionInfo: SessionMaybeProvideInfo,
|
|
|
|
|
): ReactNode {
|
|
|
|
|
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
|
|
|
|
|
@@ -667,6 +707,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
|
|
const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => {
|
|
|
|
|
const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext')
|
|
|
|
|
const hookContext = opts?.hookContext
|
|
|
|
|
// Shadowing kinds abdicate on crash (the cell falls to its next
|
|
|
|
|
// survivor); chain reports without abdicating — election alternatives
|
|
|
|
|
// resolve at select time, and retiring a crashed elected entry would
|
|
|
|
|
// change the static crash face.
|
|
|
|
|
const onEntryError = (error: unknown) => {
|
|
|
|
|
host.reportEntryError(slotKey, entry, error, { abdicate: spec.kind !== 'chain' })
|
|
|
|
|
}
|
|
|
|
|
return spec.scope === 'session'
|
|
|
|
|
? (
|
|
|
|
|
<StrictSessionEntry
|
|
|
|
|
@@ -676,11 +723,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
|
|
slotInjected={slotInjected}
|
|
|
|
|
hookContext={hookContext}
|
|
|
|
|
hasHookContext={hasHookContext}
|
|
|
|
|
onEntryError={onEntryError}
|
|
|
|
|
key={key}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
: (
|
|
|
|
|
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
|
|
|
|
<SlotErrorBoundary slotKey={slotKey} key={key} onEntryError={onEntryError}>
|
|
|
|
|
{spec.scope === 'session-maybe'
|
|
|
|
|
? (
|
|
|
|
|
<SessionMaybeEntry
|
|
|
|
|
@@ -705,15 +753,22 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
|
|
</SlotErrorBoundary>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
// A cell whose every registration abdicated keeps the crash face: the
|
|
|
|
|
// shadowing collapse ran out of survivors, which is a failure state, not
|
|
|
|
|
// the owner's natural-empty fallback.
|
|
|
|
|
const deadCell = () => <div data-slot-error={slotKey} />
|
|
|
|
|
|
|
|
|
|
if (spec.kind === 'single') {
|
|
|
|
|
const entry = entries[0]
|
|
|
|
|
if (!entry) return <>{opts?.fallback ?? null}</>
|
|
|
|
|
const entry = host.entriesOfSlot(slotKey)[0]
|
|
|
|
|
if (!entry) return entries.length > 0 ? deadCell() : <>{opts?.fallback ?? null}</>
|
|
|
|
|
return guarded(entry, entryKeyOf(entry))
|
|
|
|
|
}
|
|
|
|
|
if (spec.kind === 'keyed') {
|
|
|
|
|
const entry = entries.find(e => e.options.key === opts?.entryKey)
|
|
|
|
|
if (!entry) return <>{opts?.fallback ?? null}</>
|
|
|
|
|
const entry = host.entriesOfSlot(slotKey).find(e => e.options.key === opts?.entryKey)
|
|
|
|
|
if (!entry) {
|
|
|
|
|
const occupied = entries.some(e => e.options.key === opts?.entryKey)
|
|
|
|
|
return occupied ? deadCell() : <>{opts?.fallback ?? null}</>
|
|
|
|
|
}
|
|
|
|
|
return guarded(entry, entryKeyOf(entry))
|
|
|
|
|
}
|
|
|
|
|
if (spec.kind === 'chain') {
|
|
|
|
|
@@ -764,16 +819,35 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|
|
|
|
}
|
|
|
|
|
return elected ?? <>{opts?.fallback ?? null}</>
|
|
|
|
|
}
|
|
|
|
|
// list: registration order refined by explicit order, optional id filter.
|
|
|
|
|
const withListOptions = entries.map(entry => ({
|
|
|
|
|
// list: one row per id cell — the cell's shadowing winner, or the crash
|
|
|
|
|
// face once every entry of the cell abdicated (a dry cell must not
|
|
|
|
|
// silently drop its row). Row sequence: registration order refined by
|
|
|
|
|
// explicit order, optional id filter, as before shadowing existed.
|
|
|
|
|
const winners = host.entriesOfSlot(slotKey)
|
|
|
|
|
const rows: { entry: StoredEntry | undefined; id: string | undefined; order: number }[] = winners.map(entry => ({
|
|
|
|
|
entry,
|
|
|
|
|
id: entry.options.id,
|
|
|
|
|
order: entry.options.order ?? 0,
|
|
|
|
|
}))
|
|
|
|
|
let list = [...withListOptions].sort((a, b) => a.order - b.order)
|
|
|
|
|
const rowIds = new Set(rows.map(row => row.id))
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (rowIds.has(entry.options.id)) continue
|
|
|
|
|
rowIds.add(entry.options.id)
|
|
|
|
|
// Dry cells anchor their row at the cell head's declared order.
|
|
|
|
|
rows.push({ entry: undefined, id: entry.options.id, order: entry.options.order ?? 0 })
|
|
|
|
|
}
|
|
|
|
|
let list = [...rows].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 => guarded(item.entry, entryKeyOf(item.entry)))}</>
|
|
|
|
|
// Winner rows key by entry identity (see entryKeyOf); dry-cell rows key by
|
|
|
|
|
// id — the disjoint prefixes keep the two namespaces from colliding.
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{list.map((item, i) => item.entry !== undefined
|
|
|
|
|
? guarded(item.entry, `e${entryKeyOf(item.entry)}`)
|
|
|
|
|
: <div data-slot-error={slotKey} key={`x${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. */
|
|
|
|
|
@@ -784,19 +858,33 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
|
|
|
|
|
() => 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)")
|
|
|
|
|
const entry = host.entriesOfSlot('root')[0]
|
|
|
|
|
if (!entry) {
|
|
|
|
|
// Registrations exist but every one abdicated: the shadowing collapse ran
|
|
|
|
|
// dry, so the crash face replaces the tree (registered-but-broken is a
|
|
|
|
|
// crash, not the boot-order assembly failure below).
|
|
|
|
|
if (host.entriesOf('root').length > 0) return <div data-slot-error="root" />
|
|
|
|
|
throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
|
|
|
|
|
}
|
|
|
|
|
// Same anchor contract as SlotOutlet: 'root' is a slot like any other, and
|
|
|
|
|
// display:contents keeps the wrapper out of the shell's layout.
|
|
|
|
|
return (
|
|
|
|
|
<SlotErrorBoundary slotKey="root" key={entryKeyOf(entry)}>
|
|
|
|
|
<RootEntry
|
|
|
|
|
entry={entry}
|
|
|
|
|
ownerProps={ownerProps}
|
|
|
|
|
<div data-slot="root" style={ANCHOR_STYLE}>
|
|
|
|
|
<SlotErrorBoundary
|
|
|
|
|
slotKey="root"
|
|
|
|
|
slotInjected={EMPTY_SLOT_INJECT}
|
|
|
|
|
hookContext={undefined}
|
|
|
|
|
hasHookContext={false}
|
|
|
|
|
/>
|
|
|
|
|
</SlotErrorBoundary>
|
|
|
|
|
key={entryKeyOf(entry)}
|
|
|
|
|
onEntryError={(error) => { host.reportEntryError('root', entry, error, { abdicate: true }) }}
|
|
|
|
|
>
|
|
|
|
|
<RootEntry
|
|
|
|
|
entry={entry}
|
|
|
|
|
ownerProps={ownerProps}
|
|
|
|
|
slotKey="root"
|
|
|
|
|
slotInjected={EMPTY_SLOT_INJECT}
|
|
|
|
|
hookContext={undefined}
|
|
|
|
|
hasHookContext={false}
|
|
|
|
|
/>
|
|
|
|
|
</SlotErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|