feat: slot system + entries/priority/errorreport + typert generator

This commit is contained in:
imccyu
2026-08-12 21:59:23 +08:00
parent eec7f2ec74
commit 0367506471
28 changed files with 777 additions and 165 deletions

View File

@@ -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>
)
}

View File

@@ -31,6 +31,8 @@ function hostOver(core: SlotCore): SlotRendererHost {
subscribe: (key, fn) => core.subscribe(key, fn),
getVersion: key => core.getVersion(key),
entriesOf: key => core.entries(key),
entriesOfSlot: key => core.entriesOfSlot(key),
reportEntryError: (key, entry, error, info) => { core.reportEntryError(key, entry, error, info) },
specOf: key => core.specDynamic(key),
isLive: entry => core.isLive(entry),
storeOf: () => undefined,

View File

@@ -83,6 +83,7 @@ function makeHost() {
const versions = new Map<string, number>()
const subs = new Map<string, Set<() => void>>()
const live = new Set<StoredEntry>()
const abdicated = new Set<StoredEntry>()
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const workspaces = observable<{ ids: string[] }>({ ids: [] })
@@ -105,6 +106,28 @@ function makeHost() {
},
getVersion: key => versions.get(key) ?? 0,
entriesOf: key => entries.get(key) ?? [],
entriesOfSlot: (key) => {
const all = entries.get(key) ?? []
const kind = specs.get(key)?.kind
if (kind === 'chain') return all
// Mirror the ledger projection: first live (non-abdicated) entry per
// cell (single — one cell; keyed — per key; list — per id).
const heads: StoredEntry[] = []
const seen = new Set<string | undefined>()
for (const entry of all) {
if (abdicated.has(entry)) continue
const cell = kind === 'keyed' ? entry.options.key : kind === 'list' ? entry.options.id : undefined
if (seen.has(cell)) continue
seen.add(cell)
heads.push(entry)
}
return heads
},
reportEntryError: (key, entry, _error, info) => {
if (!info.abdicate || abdicated.has(entry)) return
abdicated.add(entry)
bump(key)
},
specOf: key => specs.get(key),
isLive: entry => live.has(entry),
storeOf: (entry, scopeKey) => {
@@ -147,11 +170,12 @@ function makeHost() {
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
const entry = entryOf(partial)
const next = [...(entries.get(key) ?? []), entry]
// Mirror the ledger contract: chain entries arrive priority-sorted
// (stable, ascending) — outlets iterate entries() order as-is.
if (specs.get(key)?.kind === 'chain') {
next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
}
// Mirror the ledger contract: entries arrive priority-sorted (stable,
// ascending; list refines equal priorities by order) — outlets iterate
// entries() order as-is.
next.sort(specs.get(key)?.kind === 'list'
? (a, b) => ((a.options.priority ?? 0) - (b.options.priority ?? 0)) || ((a.options.order ?? 0) - (b.options.order ?? 0))
: (a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
entries.set(key, next)
live.add(entry)
bump(key)

View File

@@ -46,6 +46,10 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
subscribe: () => () => {},
getVersion: () => 0,
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
// Single-kind everywhere and no crashes in this suite: the projection is
// the raw view and crash reports never fire.
entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
reportEntryError: () => {},
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,

View File

@@ -35,6 +35,10 @@ function makeHost() {
},
getVersion: key => versions.get(key) ?? 0,
entriesOf: key => entries.get(key) ?? [],
// Single-kind everywhere and no crashes in this suite: the projection is
// the raw view and crash reports never fire.
entriesOfSlot: key => entries.get(key) ?? [],
reportEntryError: () => {},
specOf: () => ({ kind: 'single', scope: 'root' }),
isLive: entry => live.has(entry),
storeOf: () => undefined,

View File

@@ -49,6 +49,10 @@ function makeHost() {
subscribe: () => () => {},
getVersion: () => 0,
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
// Single-kind everywhere and no crashes in this suite: the projection is
// the raw view and crash reports never fire.
entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
reportEntryError: () => {},
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,