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

@@ -14,14 +14,14 @@ export interface ViewTab { id: string; label: string }
/**
* Per-session state shared by conversation, chat-view, and details slots.
* Unknown persisted view ids fall back to the first registered view.
* Unknown persisted view ids fall back to the stable Chat view.
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */
selection: SelectionTarget | null
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
/** Active conversation view id ('conversation.view' entry id); null falls back to Chat. */
view: string | null
/**
* One-shot inspect handoff: chat writes the call to reveal, the trajectory

View File

@@ -280,7 +280,7 @@
overflow-y: auto;
}
.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
flex: 1 1 0;
min-height: 0;
overflow: hidden;

View File

@@ -6,6 +6,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
import type {
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
} from '../contract/slots.ts'
import type { ViewTab } from '../contract/views.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session body contract. */
@@ -19,6 +20,15 @@ interface Breadcrumb {
readonly displayTitle: string
}
const DEFAULT_VIEW_ID = 'chat'
/** Resolve by id and keep stale persisted selections on the stable Chat fallback. */
function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined {
const requestedId = selectedId ?? DEFAULT_VIEW_ID
return tabs.find(view => view.id === requestedId)
?? tabs.find(view => view.id === DEFAULT_VIEW_ID)
}
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
const chain: Breadcrumb[] = []
const seen = new Set<SessionId>()
@@ -54,8 +64,8 @@ export function ConversationSessionHeader({
}: ConversationSessionHeaderProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const selectedId = useStore(s => s.view)
const active = resolveActiveView(tabs, selectedId)
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
@@ -131,8 +141,8 @@ export function ConversationSession({
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const selectedId = useStore(s => s.view)
const active = resolveActiveView(tabs, selectedId)
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)

View File

@@ -27,6 +27,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type {
ComposerBarOwnerProps,
} from '../src/client/contract/slots.ts'
import type { ViewTab } from '../src/client/contract/views.ts'
/** Machine-backed wiring over a sink spy. */
function fakeWiring() {
@@ -96,6 +97,8 @@ function mount(
summaryOrigin?: 'subagent'
/** A composer block another plugin raised for this session. */
composerBlock?: { reason: string }
/** Mutable view ledger used by registration-order regressions. */
viewTabs?: ViewTab[]
} = {},
) {
const root = sid('root')
@@ -123,6 +126,15 @@ function mount(
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
const viewTabs = options.viewTabs ?? [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
]
const views = {
list: () => viewTabs,
subscribe: () => () => {},
version: () => 1,
}
/** Owner share handed to the two composer tool-row seats, per render. */
const seatOwners: { key: string; owner: unknown }[] = []
let pickerOwner: unknown
@@ -146,14 +158,7 @@ function mount(
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{
list: () => [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
],
subscribe: () => () => {},
version: () => 1,
}}
views={views}
open={open}
t={t}
/>
@@ -173,14 +178,7 @@ function mount(
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{
list: () => [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
],
subscribe: () => () => {},
version: () => 1,
}}
views={views}
releaseSessionImages={vi.fn()}
bindDraftMirror={write => wiring.bindMirror(write)}
/>
@@ -442,6 +440,26 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByRole('textbox')).toBeTruthy()
})
it('keeps the Chat fallback selected by id when a view is inserted before it', () => {
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
]
const b = mount(conversationSnapshot(), undefined, undefined, { viewTabs })
// A removed dynamic view leaves its persisted id behind. The visible
// fallback is Chat and must stay Chat when another lower-order view lands.
act(() => { b.chat.actions.setView('removed-view') })
expect(b.view.getByTestId('view-chat')).toBeTruthy()
viewTabs.unshift({ id: 'new-view', label: 'New view' })
b.rerender()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
expect(b.view.queryByTestId('view-new-view')).toBeNull()
expect(b.view.getByRole('tab', { name: 'Chat' }).getAttribute('aria-selected')).toBe('true')
expect(b.view.getByRole('tab', { name: 'New view' }).getAttribute('aria-selected')).toBe('false')
})
it('rolls the pending workspace label back when switching fails', async () => {
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
const b = mount(

View File

@@ -106,3 +106,14 @@
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}
.overlayLayer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
.overlayLayer > * {
pointer-events: auto;
}

View File

@@ -20,7 +20,7 @@ import css from './AppFrame.module.css'
/** Full composed props: runtime share + child-slot render share + store share. */
export type AppFrameProps =
& PropsRuntime<'root'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'shell.overlay'>
& PropsStore<ReturnType<typeof createLayoutStore>>
/** Center column grid item (session-body building block). */
@@ -190,6 +190,9 @@ export function AppFrame({
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
<div className={css.overlayLayer} data-shell-overlay>
{renderSlot('shell.overlay', {})}
</div>
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}

View File

@@ -7,6 +7,6 @@
width: 100%;
}
.section > :last-child {
.section > :global([data-slot='settings.general.item']) > :last-child {
border-bottom: none;
}

View File

@@ -227,11 +227,39 @@
padding-left: 0;
}
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
trigger row inside owns its own geometry (38px wide row / 36px rail
circle) and hover chrome. */
/* Footer seats: Settings fills the left side and additive actions sit on the
right. Each occupant owns its button geometry and hover chrome. */
.footArea {
flex: none;
display: flex;
align-items: flex-end;
gap: 8px;
}
.settingsArea {
flex: 1;
min-width: 0;
}
.footerActions {
flex: none;
display: flex;
align-items: flex-end;
}
/* The 56px rail cannot hold two controls side by side. Keep both reachable in
the same footer, stacked in their original order. */
.collapsed .footArea {
flex-direction: column;
align-items: center;
gap: 0;
}
.collapsed .settingsArea,
.collapsed .footerActions {
flex: none;
display: flex;
justify-content: center;
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -6,7 +6,7 @@
* snap to the 56px rail (one icon each, same top-down order) fading in as the
* slide ends. The workspace/session browsing region between the New Session
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
* is the `sidebar.settings` registrant's; the shell hands them the wide flag
* holds `sidebar.settings` plus `sidebar.footer.action`; the shell hands them the wide flag
* (plus an expand request callback for the browser).
*
* The column also owns whether the scroll regions nested in it draw a
@@ -177,9 +177,14 @@ export function SidebarRoot({
})}
</div>
{/* Foot seat: ui-settings registers the trigger row + panel here. */}
{/* Footer: Settings stays on the left; optional actions sit beside it. */}
<div className={css.footArea}>
{renderSlot('sidebar.settings', { wide })}
<div className={css.settingsArea}>
{renderSlot('sidebar.settings', { wide })}
</div>
<div className={css.footerActions}>
{renderSlot('sidebar.footer.action', { wide })}
</div>
</div>
</div>
)

View File

@@ -50,6 +50,12 @@ export interface SidebarSettingsOwnerProps {
wide: boolean
}
/** Owner share of an action rendered beside Settings at the sidebar foot. */
export interface SidebarFooterActionOwnerProps {
/** Whether the sidebar renders wide content (false = 56px rail). */
wide: boolean
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). The shell keeps only its own controls: starting a Session from
@@ -72,5 +78,6 @@ export type SidebarRootInjected = {
* seat. No store is registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
PropsRuntime<'sidebar'>
& PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action'>
& SidebarRootInjected & PropsLocale<'sidebar'>

View File

@@ -6,7 +6,10 @@ import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
import { en, zh, type SidebarKey } from './locales.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
export type {
SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarRootInjected,
SidebarSectionOwnerProps, SidebarSettingsOwnerProps,
} from './contract/slots.ts'
export type { SidebarKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -44,6 +47,7 @@ export function apply(ctx: ClientContext): void {
children: {
'sidebar.workspaces': { kind: 'single', scope: 'root' },
'sidebar.settings': { kind: 'single', scope: 'root' },
'sidebar.footer.action': { kind: 'list', scope: 'root' },
},
inject: injectProps,
}, SidebarRoot),

View File

@@ -31,11 +31,13 @@ describe('ui-sidebar apply', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
})
it('registers the shell and declares the browsing-region hole', async () => {
it('registers the shell and declares its child seats', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' })
// Copy rides the standard locale seat, not the inject face.
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
@@ -61,5 +63,6 @@ describe('ui-sidebar apply', () => {
await fiber.dispose()
expect(b.slots.entries('sidebar')).toHaveLength(0)
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
expect(b.slots.spec('sidebar.footer.action')).toBeUndefined()
})
})

View File

@@ -1,7 +1,10 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
import type {
SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
SidebarSettingsOwnerProps,
} from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import { en } from '../src/client/locales.ts'
@@ -23,17 +26,25 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
const toggleSidebar = vi.fn()
let regionOwner: SidebarSectionOwnerProps | undefined
let settingsOwner: SidebarSettingsOwnerProps | undefined
let footerActionOwner: SidebarFooterActionOwnerProps | undefined
let current = { collapsed, width }
const root = () => (
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={neverHook} useWorkspaces={neverHook}
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
renderSlot={((
key: string,
owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
) => {
if (key === 'sidebar.settings') {
settingsOwner = owner
return <div data-testid="settings-seat" data-wide={owner.wide} />
}
if (key === 'sidebar.footer.action') {
footerActionOwner = owner
return <div data-testid="footer-action-seat" data-wide={owner.wide} />
}
regionOwner = owner as SidebarSectionOwnerProps
return <div data-testid="region" data-wide={owner.wide} />
}) as SidebarRootComponentProps['renderSlot']}
@@ -51,6 +62,10 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
if (settingsOwner === undefined) throw new Error('settings owner not rendered')
return settingsOwner
},
footerActionOwner: () => {
if (footerActionOwner === undefined) throw new Error('footer action owner not rendered')
return footerActionOwner
},
rerender(next: Partial<typeof current>) {
current = { ...current, ...next }
view.rerender(root())
@@ -75,6 +90,7 @@ describe('SidebarRoot shell', () => {
expect(b.regionOwner().wide).toBe(true)
// The settings seat rides the same wide flag (ui-settings renders the row).
expect(b.settingsOwner().wide).toBe(true)
expect(b.footerActionOwner().wide).toBe(true)
// Expanded: the request is a no-op (no accidental collapse).
b.regionOwner().expandSidebar()
expect(b.toggleSidebar).not.toHaveBeenCalled()
@@ -89,6 +105,7 @@ describe('SidebarRoot shell', () => {
vi.advanceTimersByTime(200)
b.rerender({})
expect(b.regionOwner().wide).toBe(false)
expect(b.footerActionOwner().wide).toBe(false)
expect(screen.getByTestId('region')).toBeTruthy()
b.regionOwner().expandSidebar()
expect(b.toggleSidebar).toHaveBeenCalledOnce()

View File

@@ -473,21 +473,40 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
*/
export type SlotLabel = string | (() => string)
/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */
/**
* Kind shape fields carried in register options (keyed dispatch key; list
* id/order/label; chain select/priority; non-chain priority = cell shadowing rank).
*/
export type KindOptions<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
M = never,
> =
SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey }
: SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel }
SlotMap[K]['kind'] extends 'keyed' ? {
key: EntryKey
/** Cell shadowing rank (ascending, default 0, lowest renders; same key + same priority throws — see {@link SlotCore.register}). */
priority?: number
}
: SlotMap[K]['kind'] extends 'list' ? {
id: string
order?: number
label?: SlotLabel
/** Cell shadowing rank (ascending, default 0, lowest renders; same id + same priority throws — see {@link SlotCore.register}). */
priority?: number
}
: SlotMap[K]['kind'] extends 'chain' ? {
/** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */
select: ChainSelect<SlotMap[K] extends { owner: infer O extends object } ? O : object, M>
/** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
priority?: number
}
: object
: {
/**
* Cell shadowing rank (ascending, default 0, lowest renders; a
* same-priority second registration throws — see {@link SlotCore.register}).
*/
priority?: number
}
/**
* Compile-time presence check: an entry declaring children MUST consume
@@ -596,6 +615,8 @@ interface SlotRecord {
spec: SlotSpec<SlotEntryDef> | undefined
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
declaredBy: string | undefined
/** Live parent declaration, absent for root slots. */
parent: string | undefined
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
declarationEpoch: number
entries: readonly StoredEntry[]
@@ -606,6 +627,38 @@ interface SlotRecord {
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
/** JSON-safe live occupant returned by slot inspection. */
export interface LiveSlotOccupant {
/** Plugin or package that registered the entry, when known. */
registrant?: string
/** Keyed-slot cell. */
key?: string
/** List-slot cell. */
id?: string
/** List display order. */
order?: number
/** Shadowing or chain priority. */
priority: number
/** Whether the renderer currently selects this entry. */
active: boolean
}
/** JSON-safe live slot declaration tree. */
export interface LiveSlotNode {
/** Exact SlotMap key. */
name: string
/** Slot cardinality. */
kind: SlotKind
/** Runtime data scope. */
scope: SlotScope
/** Diagnostic owner of this declaration. */
declaredBy?: string
/** Current registrations in ledger order. */
occupants: LiveSlotOccupant[]
/** Slots declared by entries mounted in this slot. */
children: LiveSlotNode[]
}
/**
* Pure slot registry (no cordis; event emission and the renderer installation contract
* live in the runtime Service wrapper).
@@ -618,7 +671,9 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
* fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
* batch per microtask, so N same-tick mutations produce one notification per
* touched key.
* touched key. Entry crash reports ({@link SlotCore.reportEntryError}) ride
* the same mutation channel when they abdicate, then notify
* {@link SlotCore.onEntryError} synchronously.
*/
export class SlotCore {
private records = new Map<string, SlotRecord>()
@@ -629,6 +684,16 @@ export class SlotCore {
// reference skips a lookup (and an unreachable missing-record branch) at flush.
private dirty = new Set<SlotRecord>()
private flushScheduled = false
/**
* Entries retired by an abdicating crash report
* ({@link SlotCore.reportEntryError}): excluded from
* {@link SlotCore.entriesOfSlot} projections for the rest of their
* registration's life, while the registration itself stays on the ledger
* (disposal authority remains with the registrant).
*/
private abdicated = new WeakSet<StoredEntry>()
private entryErrorListeners
= new Set<(key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void>()
constructor() {
// The a-priori root hole. No markDirty: nothing can observe construction.
@@ -646,11 +711,18 @@ export class SlotCore {
* 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
* of different scopes throws. Kind constraints: keyed — missing `key`
* throws; list — missing `id` throws; chain — missing `select` throws (the
* selector is the entry's routing seat, see {@link ChainSelect}).
*
* Shadowing (single/keyed/list): entries sharing one cell (single — the
* slot itself; keyed — same `key`; list — same `id`) coexist at distinct
* priorities, sorted ascending with ties keeping registration order; the
* cell's lowest live entry renders ({@link SlotCore.entriesOfSlot}). A
* second registration at an occupied cell's exact priority (default 0)
* throws naming the occupant, so priority-less composition keeps the
* historical one-occupant-per-cell fail-loud.
*
* 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.
@@ -719,23 +791,33 @@ export class SlotCore {
}
const spec = rec.spec
// Kind constraints stay runtime checks for dynamically-composed callers;
// typed callers already satisfied KindOptions statically.
// typed callers already satisfied KindOptions statically. Cell occupancy
// clashes only at the exact priority: a different priority shadows.
const priority = options.priority ?? 0
const occupantHint = (occupant: StoredEntry) =>
`at priority ${priority}${occupant.registrant !== undefined ? ` (registered by ${occupant.registrant})` : ''} — register at a different priority to shadow it (lowest renders)`
switch (spec.kind) {
case 'single':
if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`)
case 'single': {
const occupant = rec.entries.find(e => (e.options.priority ?? 0) === priority)
if (occupant) throw new Error(`single slot "${options.name}" already has a registration ${occupantHint(occupant)}`)
break
case 'keyed':
}
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}"`)
const occupant = rec.entries.find(e => e.options.key === options.key && (e.options.priority ?? 0) === priority)
if (occupant) {
throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}" ${occupantHint(occupant)}`)
}
break
case 'list':
}
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}"`)
const occupant = rec.entries.find(e => e.options.id === options.id && (e.options.priority ?? 0) === priority)
if (occupant) {
throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`)
}
break
}
case 'chain':
if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`)
break
@@ -777,10 +859,13 @@ export class SlotCore {
...(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))
// Stable sorts: priority ascending for every kind, ties keep registration
// sequence — a cell's winner is its first occurrence, chain tries lower
// priority first. List refines equal priorities by explicit `order` so the
// raw ledger keeps its display sequence for priority-less compositions.
next.sort(spec.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))
rec.entries = next
this.markDirty(options.name, rec)
if (options.children) {
@@ -789,6 +874,7 @@ export class SlotCore {
const childRec = this.record(childKey)
childRec.spec = childSpec
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
childRec.parent = options.name
childRec.declarationEpoch += 1
declarations.push([childKey, childRec])
}
@@ -835,6 +921,36 @@ export class SlotCore {
return this.records.get(key)?.entries ?? NO_ENTRIES
}
/**
* Project a key's entries to its shadowing winners: the first live
* (non-abdicated) entry of each cell in priority order — single: the slot
* is one cell; keyed: one cell per `key`; list: one cell per `id` (winners
* keep ledger sequence; list renderers still refine display by `order`).
* Chain keys return the raw entries unchanged: election consumes every
* entry, shadowing does not apply. The raw {@link SlotCore.entries} view
* stays the inspection surface. Builds a fresh array per call — a render
* body read, not a uSES getSnapshot source.
* @param key - slot key (dynamic: the render machinery holds keys as strings).
* @returns the winning entry per occupied cell (empty while undeclared).
*/
entriesOfSlot(key: string): readonly StoredEntry[] {
const rec = this.records.get(key)
if (!rec?.spec) return NO_ENTRIES
const kind = rec.spec.kind
if (kind === 'chain') return rec.entries
const heads: StoredEntry[] = []
const seenCells = new Set<string | undefined>()
for (const entry of rec.entries) {
if (this.abdicated.has(entry)) continue
// Single-kind entries all share the one undefined cell.
const cell = kind === 'keyed' ? entry.options.key : kind === 'list' ? entry.options.id : undefined
if (seenCells.has(cell)) continue
seenCells.add(cell)
heads.push(entry)
}
return heads
}
/**
* Look up a slot's declared spec, narrowed by the SlotMap key.
* @param key - SlotMap key.
@@ -855,6 +971,53 @@ export class SlotCore {
return this.records.get(key)?.spec
}
/**
* Export the current declaration topology without components or executable hooks.
* @param root - exact Slot key to select; omitted returns every live root.
* @returns selected live Slot trees, or an empty array when `root` is unavailable.
*/
snapshot(root?: string): LiveSlotNode[] {
const build = (name: string, seen: Set<string>): LiveSlotNode | undefined => {
const record = this.records.get(name)
if (record?.spec === undefined || seen.has(name)) return undefined
const branch = new Set(seen)
branch.add(name)
const active = new Set(this.entriesOfSlot(name))
const children = [...this.records.entries()]
.filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === name)
.flatMap(([child]) => {
const node = build(child, branch)
return node === undefined ? [] : [node]
})
return {
name,
kind: record.spec.kind,
scope: record.spec.scope,
...record.declaredBy === undefined ? {} : { declaredBy: record.declaredBy },
occupants: record.entries.map(entry => ({
...entry.registrant === undefined ? {} : { registrant: entry.registrant },
...entry.options.key === undefined ? {} : { key: entry.options.key },
...entry.options.id === undefined ? {} : { id: entry.options.id },
...entry.options.order === undefined ? {} : { order: entry.options.order },
priority: entry.options.priority ?? 0,
active: active.has(entry),
})),
children,
}
}
if (root !== undefined) {
const node = build(root, new Set())
return node === undefined ? [] : [node]
}
return [...this.records.entries()]
.filter(([, record]) => record.spec !== undefined
&& (record.parent === undefined || this.records.get(record.parent)?.spec === undefined))
.flatMap(([name]) => {
const node = build(name, new Set())
return node === undefined ? [] : [node]
})
}
/**
* Read the declaration lifetime of a key. Entry additions and removals do
* not change it; declaration creation and collapse each advance it.
@@ -916,6 +1079,47 @@ export class SlotCore {
return () => { this.mutateListeners.delete(fn) }
}
/**
* Renderer crash report from an entry boundary. Always notifies
* {@link SlotCore.onEntryError} listeners; with `info.abdicate` set (the
* shadowing kinds — single/keyed/list) it first retires the entry from its
* cell, one-shot: the record's version bumps through the ordinary mutation
* channel so outlets re-project onto the cell's next survivor, and a
* repeat abdicating report no-ops entirely. Chain crashes report with
* `abdicate: false` — election alternatives resolve at select time, so the
* entry keeps its cell and only the notification fires. The registration
* itself stays on the ledger either way — raw {@link SlotCore.entries}
* still lists the entry and its disposer keeps working.
* @param key - slot key the entry rendered under.
* @param entry - the crashed entry.
* @param error - the crash cause, forwarded to listeners verbatim.
* @param info - `abdicate`: whether the crash retires the entry from its cell.
*/
reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void {
if (info.abdicate) {
if (this.abdicated.has(entry)) return
this.abdicated.add(entry)
const rec = this.records.get(key)
if (rec !== undefined) this.markDirty(key, rec)
}
for (const fn of [...this.entryErrorListeners]) fn(key, entry, error, { abdicated: info.abdicate })
}
/**
* Observe entry boundary crashes (every render-time entry failure the
* boundaries contain, abdicating or not) — the supervision seam for hosts
* mirroring contribution health. Fires synchronously per report, after the
* registry mutated for abdicating crashes (same listener discipline as
* {@link SlotCore.onMutate}).
* @param fn - called with the slot key, the crashed entry, the crash
* cause, and `abdicated`: whether the crash retired the entry from its cell.
* @returns unsubscribe.
*/
onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
this.entryErrorListeners.add(fn)
return () => { this.entryErrorListeners.delete(fn) }
}
/**
* Cascade for a removed entry: release its store mount and collapse every
* child slot it declared — specs clear, contributions empty (their stale
@@ -935,6 +1139,7 @@ export class SlotCore {
const doomed = childRec.entries
childRec.spec = undefined
childRec.declaredBy = undefined
childRec.parent = undefined
childRec.declarationEpoch += 1
childRec.entries = NO_ENTRIES
this.markDirty(childKey, childRec)
@@ -949,6 +1154,7 @@ export class SlotCore {
rec = {
spec: undefined,
declaredBy: undefined,
parent: undefined,
declarationEpoch: 0,
entries: NO_ENTRIES,
version: 0,

View File

@@ -118,6 +118,27 @@ export interface SlotRendererHost {
* @returns entries in registration (list: order) sequence.
*/
entriesOf(key: string): readonly StoredEntry[]
/**
* Shadowing winners per cell for a key — the render read for single/keyed/
* list dispatch: the first live (non-abdicated) entry of each cell in
* priority order; chain keys pass through unchanged (election consumes
* every entry). Fresh array per call — a render-body read, not a uSES
* getSnapshot source.
* @param key - slot key.
* @returns the winning entry per occupied cell.
*/
entriesOfSlot(key: string): readonly StoredEntry[]
/**
* Report an entry boundary crash. With `info.abdicate` (shadowing kinds)
* the entry retires from its cell, one-shot, so the next survivor renders;
* chain crashes report without abdicating. The registration stays on the
* ledger either way.
* @param key - slot key the entry rendered under.
* @param entry - the crashed entry.
* @param error - the crash cause.
* @param info - `abdicate`: whether the crash retires the entry from its cell.
*/
reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void
/**
* Declared runtime spec from the declarations ledger.
* @param key - slot key.

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,