refactor(gui): slot system standard — single register, four props shares, framework store seat
The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:
- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
authorization + runtime spec in one options object; misconfiguration fails
loud at load (duplicate declaration, undeclared contribution, one store
handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
(owner params + session/global standard kits via declare-merge),
PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
read = useStore, write = baked actions only; store scope derives from the
mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
React-free; ownership ledger keyed to the single entry axis closes the
stale-authority window (StaleAuthorizationError probes).
Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.
Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).
docs(ui-sidebar): point contract reference at the committed slot standard RFC
missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Stale renderSlot bindings (slot terminal design §9): a binding dies with
|
||||
* its entry — a retained closure invoked after the entry's disposal throws
|
||||
* StaleAuthorizationError off the ledger check, and an HMR-style reload (new
|
||||
* entry, same key) mints a NEW binding rather than reviving the old one.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, StaleAuthorizationError,
|
||||
type RenderOpts, type SlotRendererHost,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
|
||||
type DeclaredSpec = SlotSpec<SlotEntryDef>
|
||||
|
||||
/** Ledger-shaped fake: add/dispose maintain the live set the way the runtime ledger does. */
|
||||
function makeHost() {
|
||||
const entries = new Map<string, StoredEntry[]>()
|
||||
const versions = new Map<string, number>()
|
||||
const subs = new Map<string, Set<() => void>>()
|
||||
const live = new Set<StoredEntry>()
|
||||
const bump = (key: string) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
for (const fn of [...(subs.get(key) ?? [])]) fn()
|
||||
}
|
||||
const host: SlotRendererHost = {
|
||||
subscribe: (key, fn) => {
|
||||
const set = subs.get(key) ?? new Set()
|
||||
set.add(fn)
|
||||
subs.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
getVersion: (key) => versions.get(key) ?? 0,
|
||||
entriesOf: (key) => entries.get(key) ?? [],
|
||||
specOf: () => ({ kind: 'single', scope: 'root' }),
|
||||
isLive: (entry) => live.has(entry),
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
cell: () => undefined,
|
||||
},
|
||||
}
|
||||
return {
|
||||
host,
|
||||
add: (key: string, entry: StoredEntry) => {
|
||||
entries.set(key, [...(entries.get(key) ?? []), entry])
|
||||
live.add(entry)
|
||||
bump(key)
|
||||
return () => {
|
||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
||||
live.delete(entry)
|
||||
bump(key)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const CHILD: DeclaredSpec = { kind: 'single', scope: 'root' }
|
||||
|
||||
/**
|
||||
* Mount a root entry that leaks its binding to the test, then render. The
|
||||
* returned dispose unmounts the view FIRST: an empty 'root' makes the live
|
||||
* root outlet rethrow its boot-order failure (fail-loud, covered in the
|
||||
* scoped-slots suite); the retained-closure scenario under test here is a
|
||||
* dead entry whose binding outlives the tree.
|
||||
*/
|
||||
function mountCapturing(h: ReturnType<typeof makeHost>) {
|
||||
let captured: RenderSlotFn | undefined
|
||||
const entry: StoredEntry = {
|
||||
component: (props: { renderSlot: RenderSlotFn }) => {
|
||||
captured = props.renderSlot
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
children: { 'k.child': CHILD },
|
||||
}
|
||||
const disposeEntry = h.add('root', entry)
|
||||
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
return {
|
||||
binding: captured!,
|
||||
entry,
|
||||
dispose: () => {
|
||||
view.unmount()
|
||||
disposeEntry()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('stale authorization', () => {
|
||||
it('a live binding renders; the same closure throws after its entry is disposed', () => {
|
||||
const h = makeHost()
|
||||
const { binding, dispose } = mountCapturing(h)
|
||||
expect(binding('k.child', {})).not.toBeUndefined() // live: returns an element
|
||||
act(() => { dispose() })
|
||||
expect(() => binding('k.child', {})).toThrow(StaleAuthorizationError)
|
||||
expect(() => binding('k.child', {})).toThrow(/disposed registration/)
|
||||
})
|
||||
|
||||
it('stale check precedes the ownership check: a dead binding throws stale even for undeclared keys', () => {
|
||||
const h = makeHost()
|
||||
const { binding, dispose } = mountCapturing(h)
|
||||
act(() => { dispose() })
|
||||
// Were ownership checked first this would be SlotOwnershipError; the dead
|
||||
// entry must fail on liveness regardless of the key asked for.
|
||||
expect(() => binding('k.undeclared', {})).toThrow(StaleAuthorizationError)
|
||||
})
|
||||
|
||||
it('HMR reload (same key, new entry) mints a fresh binding; the old one stays dead', () => {
|
||||
const h = makeHost()
|
||||
const first = mountCapturing(h)
|
||||
act(() => { first.dispose() })
|
||||
|
||||
// Reload: a new entry object for the same slot key (new registration identity).
|
||||
let secondBinding: RenderSlotFn | undefined
|
||||
const secondEntry: StoredEntry = {
|
||||
component: (props: { renderSlot: RenderSlotFn }) => {
|
||||
secondBinding = props.renderSlot
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
children: { 'k.child': CHILD },
|
||||
}
|
||||
h.add('root', secondEntry)
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
|
||||
expect(secondBinding).toBeDefined()
|
||||
expect(secondBinding).not.toBe(first.binding) // new identity, no revival
|
||||
expect(secondBinding!('k.child', {})).not.toBeUndefined() // new binding is live
|
||||
expect(() => first.binding('k.child', {})).toThrow(StaleAuthorizationError) // old stays dead
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user