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:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions
@@ -1,106 +1,143 @@
// @vitest-environment jsdom
/**
* SessionProvider behavior account (render-prop form, framework-wired):
* empty/body branching off the host's current-session source, key={sessionId}
* remount semantics, and cell delivery observed through a session slot's
* standard kit — never through the internal context objects (BindingContext
* does not leave the package).
*/
import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSessionProvider, createSnapshotStore, RootBindingProvider,
useRootBinding, useSessionBinding,
type SessionBinding, type SessionProviderDeps,
createSlotRenderer, SessionProvider,
type SessionCell, type SlotRendererHost,
} from '@deepseek-ai/dsh-client-web-react'
const makeBinding = (sessionId: string): SessionBinding => ({
sessionId,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: { tag: sessionId },
})
function setup(bindings: Record<string, SessionBinding>) {
const current = createSnapshotStore<{ id: string | undefined }>({ id: undefined })
const resolveBinding = vi.fn((id: string) => bindings[id])
const seen: { id: string; binding: SessionBinding; mountCount: number }[] = []
let mounts = 0
function Body({ id }: { id: string }) {
const binding = useSessionBinding()
const mountRef = useRef(0)
useEffect(() => { mounts += 1; mountRef.current = mounts }, [])
seen.push({ id, binding, mountCount: mountRef.current })
return <div data-testid="body">{id}</div>
function observable<T>(initial: T) {
let value = initial
const subs = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
}
const deps: SessionProviderDeps = {
useCurrent: () => current.useSelector((s) => s.id),
resolveBinding,
renderBody: (id) => <Body id={id} />,
}
const SessionProvider = createSessionProvider(deps)
return { current, resolveBinding, SessionProvider, seen, mountCount: () => mounts }
}
describe('createSessionProvider', () => {
it('renders empty without a current session and switches to the body on select', () => {
const { current, SessionProvider } = setup({ s1: makeBinding('s1') })
const view = render(<SessionProvider renderEmpty={() => <span>empty</span>} />)
/**
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
const current = observable<string | undefined>(undefined)
const cells = new Map<string, SessionCell>()
const sessionEntries: StoredEntry[] = []
const rootEntry: StoredEntry = {
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
<>{bodies.root(props.renderSlot)}</>,
options: {},
children: { 'k.session': { kind: 'single', scope: 'session' } },
}
const host: SlotRendererHost = {
subscribe: () => () => {},
getVersion: () => 0,
entriesOf: (key) => key === 'root' ? [rootEntry] : sessionEntries,
specOf: (key) => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,
sessions: {
list: observable<unknown>({ ids: [] }),
current,
cell: (id) => cells.get(id),
},
}
return {
host,
current,
addSession: (id: string) => {
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
cells.set(id, cell)
return cell
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
describe('SessionProvider', () => {
it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
const h = makeHost({
root: () => (
<SessionProvider empty={() => <span>empty</span>}>
{(id) => <div data-testid="body">{id}</div>}
</SessionProvider>
),
})
h.addSession('s1')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
act(() => { current.update((d) => { d.id = 's1' }) })
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
expect(view.container.textContent).toBe('empty')
})
it('renders null empty state when renderEmpty is omitted', () => {
const { SessionProvider } = setup({})
const view = render(<SessionProvider />)
it('renders null empty state when the empty prop is omitted', () => {
const h = makeHost({
root: () => <SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('')
})
it('falls back to empty when the binding does not resolve', () => {
const { current, SessionProvider } = setup({})
const view = render(<SessionProvider renderEmpty={() => <span>empty</span>} />)
act(() => { current.update((d) => { d.id = 'ghost' }) })
expect(view.container.textContent).toBe('empty')
it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
let mounts = 0
function Body({ id }: { id: string }) {
const mounted = useRef(false)
useEffect(() => {
/* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
if (!mounted.current) { mounted.current = true; mounts += 1 }
}, [])
return <div>{id}</div>
}
const h = makeHost({
root: () => <SessionProvider>{(id) => <Body id={id} />}</SessionProvider>,
})
h.addSession('s1')
h.addSession('s2')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
const afterS1 = mounts
act(() => { h.current.set('s2') })
expect(mounts).toBe(afterS1 + 1)
const afterS2 = mounts
view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(mounts).toBe(afterS2)
})
it('passes the resolved binding through context and remounts on session switch', () => {
const bindings = { s1: makeBinding('s1'), s2: makeBinding('s2') }
const { current, SessionProvider, seen, mountCount } = setup(bindings)
render(<SessionProvider />)
act(() => { current.update((d) => { d.id = 's1' }) })
expect(seen.at(-1)!.binding).toBe(bindings.s1)
const mountsAfterS1 = mountCount()
act(() => { current.update((d) => { d.id = 's2' }) })
expect(seen.at(-1)!.binding).toBe(bindings.s2)
// key={id} semantics: switching sessions remounts the body subtree.
expect(mountCount()).toBe(mountsAfterS1 + 1)
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
const seen: Record<string, unknown>[] = []
const h = makeHost({
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
})
const s1 = h.addSession('s1')
const s2 = h.addSession('s2')
h.registerSession({ component: (props: object) => { seen.push(props as Record<string, unknown>); return null }, options: {} })
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)!['useSession']).toBe(s1.useSession)
expect(seen.at(-1)!['sessionId']).toBe('s1')
act(() => { h.current.set('s2') })
expect(seen.at(-1)!['useSession']).toBe(s2.useSession)
expect(seen.at(-1)!['sessionId']).toBe('s2')
})
it('does not remount the body when unrelated renders happen on the same session', () => {
const bindings = { s1: makeBinding('s1') }
const { current, SessionProvider, mountCount } = setup(bindings)
const view = render(<SessionProvider />)
act(() => { current.update((d) => { d.id = 's1' }) })
const mounts = mountCount()
view.rerender(<SessionProvider />)
expect(mountCount()).toBe(mounts)
})
})
describe('binding contexts', () => {
it('useSessionBinding throws outside a SessionProvider subtree', () => {
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
function Naked() { useSessionBinding(); return null }
expect(() => render(<Naked />)).toThrow(/outside SessionProvider/)
spy.mockRestore()
})
it('RootBindingProvider supplies the root binding; absence throws', () => {
const root: RootBinding = { ctx: { tag: 'root' } }
let got: RootBinding | undefined
function Probe() { got = useRootBinding(); return null }
render(<RootBindingProvider value={root}><Probe /></RootBindingProvider>)
expect(got).toBe(root)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<Probe />)).toThrow(/RootBindingProvider/)
expect(() => render(
<SessionProvider>{(id) => <b>{id}</b>}</SessionProvider>,
)).toThrow(/outside the installed renderer tree/)
spy.mockRestore()
})
})