Files
deepseek-harness/packages/client/web-react/tests/stale-authorization.spec.tsx
imccyu 1b0ea07bce 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.
2026-07-23 03:25:11 +08:00

137 lines
5.0 KiB
TypeScript

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