| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138 |
- // @vitest-environment jsdom
- /**
- * A retained render binding dies with its entry. Re-registering the same key
- * creates a new binding rather than reviving the stale closure.
- */
- 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: () => () => {} },
- provideInfo: () => undefined,
- maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
- },
- workspaces: {
- list: { getSnapshot: () => ({}), subscribe: () => () => {} },
- },
- }
- 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
- })
- })
|