scoped-slots.spec.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. // @vitest-environment jsdom
  2. /**
  3. * createSlotRenderer machinery account over a behavioral fake host: root
  4. * outlet + per-kind child outlets, standard-kit synthesis (renderSlot
  5. * binding, session pair, global useSessions, store pair), inject execution
  6. * point (inside component bodies, contained per entry) and parameter
  7. * derivation, and cache granularity (entry x scope key). Ledger semantics
  8. * (declaration conflicts, store instance accounting) belong to the runtime
  9. * SlotsService suite, not here.
  10. */
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { act, fireEvent, render } from '@testing-library/react'
  13. import { useEffect, type ReactNode } from 'react'
  14. import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
  15. import {
  16. createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
  17. type RenderOpts, type SessionProvideInfo,
  18. type SlotRendererHost, type StoreInstanceLike,
  19. } from '@deepseek-ai/dsh-client-web-react'
  20. type AnyProps = Record<string, unknown>
  21. type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
  22. type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode; overlay?: boolean }) => ReactNode
  23. type DeclaredSpec = SlotSpec<SlotEntryDef>
  24. /** Entry literal helper: fake entries default the mandatory options bag. */
  25. const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
  26. ({ options: {}, ...partial })
  27. /**
  28. * Minimal store handle satisfying the StoreDecl contract shape (spec +
  29. * create(scopeKey?) + instance with clearPersisted): the machinery consumes
  30. * only the StoreInstanceLike face (bare snapshot source + baked actions),
  31. * but entry.store is typed to the full contract — the real defineStore lives
  32. * in runtime, which web-react tests must not import (dependency direction).
  33. */
  34. function miniStore<T extends object>(
  35. init: () => T,
  36. mutators: Record<string, (state: T, ...params: never[]) => T>,
  37. ): StoreHandle<T, ActionsDecl<T>> {
  38. return {
  39. spec: { init, actions: {} },
  40. create: () => {
  41. let state = init()
  42. const listeners = new Set<() => void>()
  43. const actions: Record<string, (...params: never[]) => void> = {}
  44. for (const key of Object.keys(mutators)) {
  45. actions[key] = (...params: never[]) => {
  46. state = mutators[key]!(state, ...params)
  47. for (const fn of [...listeners]) fn()
  48. }
  49. }
  50. return {
  51. getSnapshot: () => state,
  52. subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
  53. actions,
  54. clearPersisted: () => {},
  55. } as StoreInstanceLike as ReturnType<StoreHandle<T, ActionsDecl<T>>['create']>
  56. },
  57. }
  58. }
  59. function observable<T>(initial: T) {
  60. let value = initial
  61. const subs = new Set<() => void>()
  62. return {
  63. getSnapshot: () => value,
  64. subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
  65. set: (next: T) => { value = next; for (const fn of [...subs]) fn() },
  66. }
  67. }
  68. /**
  69. * Behavioral SlotRendererHost fake: registration mutates entries, bumps the
  70. * key version, and notifies synchronously (batching semantics belong to the
  71. * runtime host, not this package's outlets). Store instances resolve through
  72. * the entry's real handle, cached per (entry x scope key) like the real
  73. * ledger; session cells are identity-stable per id.
  74. */
  75. function makeHost() {
  76. const entries = new Map<string, StoredEntry[]>()
  77. const specs = new Map<string, DeclaredSpec>()
  78. const versions = new Map<string, number>()
  79. const subs = new Map<string, Set<() => void>>()
  80. const live = new Set<StoredEntry>()
  81. const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
  82. const list = observable<{ ids: string[] }>({ ids: [] })
  83. const workspaces = observable<{ ids: string[] }>({ ids: [] })
  84. const current = observable<string | undefined>(undefined)
  85. const infos = new Map<string, SessionProvideInfo>()
  86. const bump = (key: string) => {
  87. versions.set(key, (versions.get(key) ?? 0) + 1)
  88. for (const fn of [...(subs.get(key) ?? [])]) fn()
  89. }
  90. const host: SlotRendererHost = {
  91. subscribe: (key, fn) => {
  92. const set = subs.get(key) ?? new Set()
  93. set.add(fn)
  94. subs.set(key, set)
  95. return () => { set.delete(fn) }
  96. },
  97. getVersion: key => versions.get(key) ?? 0,
  98. entriesOf: key => entries.get(key) ?? [],
  99. specOf: key => specs.get(key),
  100. isLive: entry => live.has(entry),
  101. storeOf: (entry, scopeKey) => {
  102. if (entry.store === undefined) return undefined
  103. let perScope = storeCache.get(entry)
  104. if (!perScope) {
  105. perScope = new Map()
  106. storeCache.set(entry, perScope)
  107. }
  108. const cacheKey = scopeKey ?? ''
  109. let instance = perScope.get(cacheKey)
  110. if (!instance) {
  111. // Fake entries always carry engine handles (never factories), and the
  112. // engine create() takes the scope key (persist suffixing).
  113. const handle = entry.store as { create(scopeKey?: string): StoreInstanceLike }
  114. instance = handle.create(scopeKey)
  115. perScope.set(cacheKey, instance)
  116. }
  117. return instance
  118. },
  119. sessions: {
  120. list,
  121. current,
  122. provideInfo: id => infos.get(id),
  123. maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
  124. ?? { sessionId: undefined, hooks: {}, props: {} },
  125. },
  126. workspaces: { list: workspaces },
  127. }
  128. return {
  129. host,
  130. list,
  131. workspaces,
  132. current,
  133. declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
  134. add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
  135. const entry = entryOf(partial)
  136. const next = [...(entries.get(key) ?? []), entry]
  137. // Mirror the ledger contract: chain entries arrive priority-sorted
  138. // (stable, ascending) — outlets iterate entries() order as-is.
  139. if (specs.get(key)?.kind === 'chain') {
  140. next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
  141. }
  142. entries.set(key, next)
  143. live.add(entry)
  144. bump(key)
  145. return () => {
  146. entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
  147. live.delete(entry)
  148. bump(key)
  149. }
  150. },
  151. addSession: (id: string): SessionProvideInfo => {
  152. // Bare source per bundle (identity-stable): the machinery binds useSession from it.
  153. const info: SessionProvideInfo = {
  154. sessionId: id,
  155. hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
  156. props: {},
  157. }
  158. infos.set(id, info)
  159. return info
  160. },
  161. }
  162. }
  163. type Fake = ReturnType<typeof makeHost>
  164. /** Mount a root entry whose component renders `body` with its kit renderSlot. */
  165. function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlot: RenderSlotFn) => ReactNode) {
  166. const dispose = h.add('root', {
  167. component: (props: { renderSlot: RenderSlotFn }) => <>{body(props.renderSlot)}</>,
  168. children,
  169. })
  170. const renderer = createSlotRenderer()
  171. const view = render(<>{renderer.renderRoot(h.host, {})}</>)
  172. return { view, dispose }
  173. }
  174. const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' }
  175. const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' }
  176. const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' }
  177. /** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */
  178. const chainEntryOf = (partial: {
  179. component: unknown
  180. select: (owner: object) => unknown
  181. priority?: number
  182. }): Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] } => ({
  183. component: partial.component,
  184. select: partial.select,
  185. ...(partial.priority !== undefined ? { options: { priority: partial.priority } } : {}),
  186. })
  187. /** Mount a root entry whose component renders `body` with its kit renderSlotChain. */
  188. function mountChainRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) {
  189. const dispose = h.add('root', {
  190. component: (props: { renderSlotChain: RenderSlotChainFn }) => <>{body(props.renderSlotChain)}</>,
  191. children,
  192. })
  193. const renderer = createSlotRenderer()
  194. const view = render(<>{renderer.renderRoot(h.host, {})}</>)
  195. return { view, dispose }
  196. }
  197. describe('root outlet', () => {
  198. it('renders the root registration and fails loud when root is unregistered (boot order)', () => {
  199. const h = makeHost()
  200. h.add('root', { component: () => <b>shell</b> })
  201. const renderer = createSlotRenderer()
  202. const view = render(<>{renderer.renderRoot(h.host, {})}</>)
  203. expect(view.container.textContent).toBe('shell')
  204. const empty = makeHost()
  205. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  206. expect(() => render(<>{createSlotRenderer().renderRoot(empty.host, {})}</>))
  207. .toThrow(/boot order/)
  208. spy.mockRestore()
  209. })
  210. it('passes renderRoot owner props into the root component', () => {
  211. const h = makeHost()
  212. h.add('root', { component: ({ tag }: { tag?: string }) => <b>{tag}</b> })
  213. const view = render(<>{createSlotRenderer().renderRoot(h.host, { tag: 'OWNER' })}</>)
  214. expect(view.container.textContent).toBe('OWNER')
  215. })
  216. })
  217. describe('child outlets and the renderSlot binding', () => {
  218. it('renders declared single slots live: fallback when empty, register, dispose back', () => {
  219. const h = makeHost()
  220. h.declare('k.single', SINGLE_ROOT)
  221. const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
  222. renderSlot => renderSlot('k.single', {}, { fallback: <i>none</i> }))
  223. expect(view.container.textContent).toBe('none')
  224. let dispose = () => {}
  225. act(() => { dispose = h.add('k.single', { component: () => <b>SB</b> }) })
  226. expect(view.container.textContent).toBe('SB')
  227. act(() => { dispose() })
  228. expect(view.container.textContent).toBe('none')
  229. })
  230. it('renders an undeclared key as empty (declaring entry unloaded = natural blank, not a crash)', () => {
  231. const h = makeHost()
  232. const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT },
  233. renderSlot => <main>{renderSlot('k.single', {}, { fallback: <i>fb</i> })}</main>)
  234. // Declared by children (authorization) but absent from the ledger (specOf
  235. // undefined): the outlet renders nothing, not even the fallback path's spec dispatch.
  236. expect(view.container.querySelector('main')!.textContent).toBe('')
  237. })
  238. it('orders list entries, honors only-filter, dispatches keyed entries by entryKey', () => {
  239. const h = makeHost()
  240. h.declare('k.list', { kind: 'list', scope: 'root' })
  241. h.declare('k.keyed', { kind: 'keyed', scope: 'root' })
  242. h.add('k.list', { component: () => <span>b</span>, options: { id: 'b', order: 2 } })
  243. h.add('k.list', { component: () => <span>a</span>, options: { id: 'a', order: 1 } })
  244. h.add('k.keyed', { component: () => <span>goal</span>, options: { key: 'goal' } })
  245. const children = { 'k.list': { kind: 'list', scope: 'root' } as DeclaredSpec, 'k.keyed': { kind: 'keyed', scope: 'root' } as DeclaredSpec }
  246. const { view } = mountRoot(h, children, renderSlot => <>
  247. <main>{renderSlot('k.list', {})}</main>
  248. <aside>{renderSlot('k.list', {}, { only: 'b' })}</aside>
  249. <nav>{renderSlot('k.keyed', {}, { entryKey: 'goal' })}</nav>
  250. <footer>{renderSlot('k.keyed', {}, { entryKey: 'nope', fallback: <i>fb</i> })}</footer>
  251. </>)
  252. expect(view.container.querySelector('main')!.textContent).toBe('ab')
  253. expect(view.container.querySelector('aside')!.textContent).toBe('b')
  254. expect(view.container.querySelector('nav')!.textContent).toBe('goal')
  255. expect(view.container.querySelector('footer')!.textContent).toBe('fb')
  256. })
  257. it('keeps the binding identity-stable across re-renders and throws SlotOwnershipError off-declaration', () => {
  258. const h = makeHost()
  259. h.declare('k.single', SINGLE_ROOT)
  260. const seen: RenderSlotFn[] = []
  261. mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => {
  262. seen.push(renderSlot)
  263. return renderSlot('k.single', {})
  264. })
  265. // Bump the 'root' key to force a root-entry re-render (the single-kind
  266. // outlet only reads entries[0], so the extra entry is inert).
  267. act(() => { h.add('root', { component: () => null }) })
  268. expect(seen.length).toBeGreaterThan(1)
  269. expect(seen.at(-1)).toBe(seen[0])
  270. expect(() => seen[0]!('k.undeclared', {})).toThrow(SlotOwnershipError)
  271. })
  272. it('isolates a crashing entry without collapsing siblings', () => {
  273. const h = makeHost()
  274. h.declare('k.list', { kind: 'list', scope: 'root' })
  275. h.add('k.list', { component: () => { throw new Error('entry boom') }, options: { id: 'bad', order: 1 } })
  276. h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
  277. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  278. const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
  279. renderSlot => renderSlot('k.list', {}))
  280. spy.mockRestore()
  281. expect(view.container.textContent).toBe('alive')
  282. expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
  283. })
  284. })
  285. describe('chain outlets and the renderSlotChain binding', () => {
  286. it('elects the first non-null selector in order, injects matched, and skips decliners without mounting them', () => {
  287. const h = makeHost()
  288. h.declare('k.chain', CHAIN_ROOT)
  289. const declinerBody = vi.fn(() => <span>never</span>)
  290. h.add('k.chain', chainEntryOf({
  291. component: declinerBody,
  292. select: () => null,
  293. }))
  294. h.add('k.chain', chainEntryOf({
  295. component: ({ matched }: { matched?: { label: string } }) => <b>{matched?.label}</b>,
  296. select: owner => ({ label: `hit:${(owner as { tag: string }).tag}` }),
  297. }))
  298. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  299. renderSlotChain => renderSlotChain('k.chain', { tag: 'T' }))
  300. // The declining entry never mounts: the routing decision is select-layer only.
  301. expect(view.container.textContent).toBe('hit:T')
  302. expect(declinerBody).not.toHaveBeenCalled()
  303. })
  304. it('contains a throwing selector to its entry: reported, treated as declined, chain and fallback intact', () => {
  305. const h = makeHost()
  306. h.declare('k.chain', CHAIN_ROOT)
  307. h.add('k.chain', chainEntryOf({
  308. component: () => <span>never</span>,
  309. select: () => { throw new Error('selector boom') },
  310. }))
  311. h.add('k.chain', chainEntryOf({
  312. component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
  313. select: owner => (owner as { pick?: string }).pick ?? null,
  314. }))
  315. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  316. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
  317. <main>{renderSlotChain('k.chain', { pick: 'OK' })}</main>
  318. <aside>{renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })}</aside>
  319. </>)
  320. // The breach never escapes to the owner region: later entries still get
  321. // tried, and an all-throw/all-null pass still lands on the fallback.
  322. expect(view.container.querySelector('main')!.textContent).toBe('OK')
  323. expect(view.container.querySelector('aside')!.textContent).toBe('fb')
  324. expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
  325. spy.mockRestore()
  326. })
  327. it('remounts the boundary on re-election: a failed entry does not black out its replacement', () => {
  328. const h = makeHost()
  329. h.declare('k.chain', CHAIN_ROOT)
  330. h.add('k.chain', chainEntryOf({
  331. component: () => { throw new Error('entry A boom') },
  332. select: owner => (owner as { pick?: string }).pick === 'A' ? {} : null,
  333. }))
  334. h.add('k.chain', chainEntryOf({
  335. component: () => <b>B-ok</b>,
  336. select: owner => (owner as { pick?: string }).pick === 'B' ? {} : null,
  337. }))
  338. let pick = 'A'
  339. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  340. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  341. renderSlotChain => renderSlotChain('k.chain', { pick }))
  342. spy.mockRestore()
  343. expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
  344. // Re-elect entry B: the entry-keyed boundary remounts fresh instead of
  345. // holding A's failed state over the healthy replacement.
  346. pick = 'B'
  347. act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site
  348. expect(view.container.textContent).toBe('B-ok')
  349. expect(view.container.querySelector('[data-slot-error]')).toBeNull()
  350. })
  351. it('falls to the owner fallback when every selector declines, and re-routes live', () => {
  352. const h = makeHost()
  353. h.declare('k.chain', CHAIN_ROOT)
  354. h.add('k.chain', chainEntryOf({
  355. component: ({ matched }: { matched?: string }) => <b>{matched}</b>,
  356. select: owner => (owner as { pick?: string }).pick ?? null,
  357. }))
  358. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, renderSlotChain => <>
  359. <main>{renderSlotChain('k.chain', {}, { fallback: <i>bar</i> })}</main>
  360. <aside>{renderSlotChain('k.chain', { pick: 'P' }, { fallback: <i>bar</i> })}</aside>
  361. </>)
  362. // Same chain, two dispatch sites: all-null owner props fall back, matching ones elect.
  363. expect(view.container.querySelector('main')!.textContent).toBe('bar')
  364. expect(view.container.querySelector('aside')!.textContent).toBe('P')
  365. })
  366. it('renders the fallback for an empty chain and elects live once an entry registers', () => {
  367. const h = makeHost()
  368. h.declare('k.chain', CHAIN_ROOT)
  369. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  370. renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>none</i> }))
  371. expect(view.container.textContent).toBe('none')
  372. let dispose = () => {}
  373. act(() => {
  374. dispose = h.add('k.chain', chainEntryOf({
  375. component: () => <b>IN</b>,
  376. select: () => ({}),
  377. }))
  378. })
  379. expect(view.container.textContent).toBe('IN')
  380. act(() => { dispose() })
  381. expect(view.container.textContent).toBe('none')
  382. })
  383. it('orders the chain by ascending priority with registration sequence breaking ties', () => {
  384. const h = makeHost()
  385. h.declare('k.chain', CHAIN_ROOT)
  386. // Registered first but priority 2: must yield to the later priority-1 entry.
  387. h.add('k.chain', chainEntryOf({
  388. component: () => <b>late</b>,
  389. select: () => ({}),
  390. priority: 2,
  391. }))
  392. h.add('k.chain', chainEntryOf({
  393. component: () => <b>early</b>,
  394. select: () => ({}),
  395. priority: 1,
  396. }))
  397. // Tie pair at priority 1: registration order decides (early wins over tie).
  398. h.add('k.chain', chainEntryOf({
  399. component: () => <b>tie</b>,
  400. select: () => ({}),
  401. priority: 1,
  402. }))
  403. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  404. renderSlotChain => renderSlotChain('k.chain', {}))
  405. expect(view.container.textContent).toBe('early')
  406. })
  407. it('keeps the renderSlotChain binding identity-stable across re-renders', () => {
  408. const h = makeHost()
  409. h.declare('k.chain', CHAIN_ROOT)
  410. const seen: RenderSlotChainFn[] = []
  411. mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => {
  412. seen.push(renderSlotChain)
  413. return renderSlotChain('k.chain', {}, { fallback: <i>fb</i> })
  414. })
  415. act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the entry
  416. expect(seen.length).toBeGreaterThan(1)
  417. expect(seen.at(-1)).toBe(seen[0])
  418. })
  419. it('backstops off-declaration keys, kind mismatches both ways, and disposed registrations', () => {
  420. const h = makeHost()
  421. h.declare('k.chain', CHAIN_ROOT)
  422. h.declare('k.single', SINGLE_ROOT)
  423. let chainFn: RenderSlotChainFn | undefined
  424. let slotFn: RenderSlotFn | undefined
  425. const dispose = h.add('root', {
  426. component: (props: { renderSlot: RenderSlotFn; renderSlotChain: RenderSlotChainFn }) => {
  427. slotFn = props.renderSlot
  428. chainFn = props.renderSlotChain
  429. return null
  430. },
  431. children: { 'k.chain': CHAIN_ROOT, 'k.single': SINGLE_ROOT },
  432. })
  433. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  434. expect(() => chainFn!('k.undeclared', {})).toThrow(SlotOwnershipError)
  435. expect(() => chainFn!('k.single', {})).toThrow(SlotOwnershipError) // non-chain key via chain face
  436. expect(() => slotFn!('k.chain', {})).toThrow(SlotOwnershipError) // chain key via plain face
  437. view.unmount()
  438. dispose()
  439. expect(() => chainFn!('k.chain', {})).toThrow(StaleAuthorizationError)
  440. })
  441. it('withholds the renderSlotChain seat from entries declaring no chain child', () => {
  442. const h = makeHost()
  443. h.declare('k.single', SINGLE_ROOT)
  444. const seen: AnyProps[] = []
  445. h.add('root', {
  446. component: (props: AnyProps) => { seen.push(props); return null },
  447. children: { 'k.single': SINGLE_ROOT },
  448. })
  449. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  450. expect(seen.at(-1)!['renderSlotChain']).toBeUndefined()
  451. })
  452. })
  453. describe('overlay chains (ChainRenderOpts.overlay)', () => {
  454. /** Fallback probe: counts mounts and holds uncontrolled DOM state (the
  455. * composer-draft stand-in an unmount would wipe). */
  456. function fallbackProbe(onMount: () => void) {
  457. return function Probe() {
  458. useEffect(onMount, [])
  459. return <input aria-label="probe" defaultValue="" />
  460. }
  461. }
  462. it('keeps the fallback mounted and state-holding through a takeover, hidden then restored', () => {
  463. const h = makeHost()
  464. h.declare('k.chain', CHAIN_ROOT)
  465. h.add('k.chain', chainEntryOf({
  466. component: () => <b>TAKEOVER</b>,
  467. select: owner => (owner as { take?: boolean }).take ? {} : null,
  468. }))
  469. const mounted = vi.fn()
  470. const Probe = fallbackProbe(mounted)
  471. let take = false
  472. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  473. renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe />, overlay: true }))
  474. const wrapper = () => view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
  475. const input = () => view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
  476. // Resident phase: fallback visible through the layout-neutral wrapper.
  477. expect(wrapper().style.display).toBe('contents')
  478. fireEvent.change(input(), { target: { value: 'draft-in-flight' } })
  479. // Election: entry overlays, fallback hides in place — same DOM node, no remount.
  480. take = true
  481. act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site
  482. expect(view.container.textContent).toContain('TAKEOVER')
  483. expect(wrapper().style.display).toBe('none')
  484. expect(input().value).toBe('draft-in-flight')
  485. // Takeover ends: fallback shows again with its state intact, still the original mount.
  486. take = false
  487. act(() => { h.add('root', { component: () => null }) })
  488. expect(view.container.textContent).not.toContain('TAKEOVER')
  489. expect(wrapper().style.display).toBe('contents')
  490. expect(input().value).toBe('draft-in-flight')
  491. expect(mounted).toHaveBeenCalledTimes(1)
  492. })
  493. it('leaves non-overlay chains on the unmount path: a takeover discards fallback state', () => {
  494. const h = makeHost()
  495. h.declare('k.chain', CHAIN_ROOT)
  496. h.add('k.chain', chainEntryOf({
  497. component: () => <b>TAKEOVER</b>,
  498. select: owner => (owner as { take?: boolean }).take ? {} : null,
  499. }))
  500. const mounted = vi.fn()
  501. const Probe = fallbackProbe(mounted)
  502. let take = false
  503. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  504. renderSlotChain => renderSlotChain('k.chain', { take }, { fallback: <Probe /> }))
  505. fireEvent.change(view.container.querySelector('input[aria-label="probe"]')!, { target: { value: 'gone' } })
  506. expect(view.container.querySelector('[data-chain-overlay-fallback]')).toBeNull()
  507. take = true
  508. act(() => { h.add('root', { component: () => null }) })
  509. expect(view.container.querySelector('input[aria-label="probe"]')).toBeNull() // unmounted
  510. take = false
  511. act(() => { h.add('root', { component: () => null }) })
  512. const remounted = view.container.querySelector<HTMLInputElement>('input[aria-label="probe"]')!
  513. expect(remounted.value).toBe('') // fresh mount, state discarded
  514. expect(mounted).toHaveBeenCalledTimes(2)
  515. })
  516. it('keeps election semantics under overlay: priority order, selector-crash decline, live dispose back to fallback', () => {
  517. const h = makeHost()
  518. h.declare('k.chain', CHAIN_ROOT)
  519. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  520. h.add('k.chain', chainEntryOf({
  521. component: () => <span>never</span>,
  522. select: () => { throw new Error('selector boom') },
  523. priority: 1,
  524. }))
  525. const dispose = h.add('k.chain', chainEntryOf({
  526. component: () => <b>ELECTED</b>,
  527. select: () => ({}),
  528. priority: 2,
  529. }))
  530. const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT },
  531. renderSlotChain => renderSlotChain('k.chain', {}, { fallback: <i>resident</i>, overlay: true }))
  532. expect(view.container.textContent).toContain('ELECTED')
  533. expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true)
  534. spy.mockRestore()
  535. act(() => { dispose() })
  536. const wrapper = view.container.querySelector<HTMLElement>('[data-chain-overlay-fallback="k.chain"]')!
  537. expect(wrapper.style.display).toBe('contents')
  538. expect(view.container.textContent).toBe('resident')
  539. })
  540. })
  541. describe('standard-kit synthesis', () => {
  542. it('delivers a live useSessions hook to every slot component', () => {
  543. const h = makeHost()
  544. h.declare('k.single', SINGLE_ROOT)
  545. h.add('k.single', {
  546. component: ({ useSessions }: { useSessions: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
  547. <b>{useSessions(s => s.ids.length)}</b>,
  548. })
  549. const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
  550. expect(view.container.textContent).toBe('0')
  551. act(() => { h.list.set({ ids: ['a', 'b'] }) })
  552. expect(view.container.textContent).toBe('2')
  553. })
  554. it('delivers a live useWorkspaces hook to every slot component', () => {
  555. const h = makeHost()
  556. h.declare('k.single', SINGLE_ROOT)
  557. h.add('k.single', {
  558. component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
  559. <b>{useWorkspaces(s => s.ids.length)}</b>,
  560. })
  561. const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
  562. expect(view.container.textContent).toBe('0')
  563. act(() => { h.workspaces.set({ ids: ['w1'] }) })
  564. expect(view.container.textContent).toBe('1')
  565. })
  566. it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
  567. const h = makeHost()
  568. h.declare('k.session', SINGLE_SESSION)
  569. h.addSession('s1')
  570. const seen: AnyProps[] = []
  571. h.add('k.session', {
  572. component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
  573. seen.push({ ...props, read: props.useSession!(s => s.sid) })
  574. return null
  575. },
  576. })
  577. mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
  578. <SessionProvider empty={() => <i>empty</i>}>
  579. {() => renderSlot('k.session', {})}
  580. </SessionProvider>
  581. ))
  582. act(() => { h.current.set('s1') })
  583. const props = seen.at(-1)!
  584. // The hook is BOUND by the machinery from the cell's bare source: it
  585. // reads the source's snapshot and stays identity-stable across renders
  586. // (per-source cache), which the switch-back cache tests cover.
  587. expect(props['read']).toBe('s1')
  588. expect(props['sessionId']).toBe('s1')
  589. })
  590. it('hands the SessionProvider seat to entries declaring a session-scope child', () => {
  591. const h = makeHost()
  592. h.declare('k.session', SINGLE_SESSION)
  593. h.declare('k.single', SINGLE_ROOT)
  594. h.addSession('s1')
  595. h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b> })
  596. const rootSeen: AnyProps[] = []
  597. // Root entry uses its INJECTED provider seat (no value import of SessionProvider).
  598. h.add('root', {
  599. component: (props: AnyProps) => {
  600. rootSeen.push(props)
  601. const Provider = props['SessionProvider'] as typeof SessionProvider
  602. const renderSlot = props['renderSlot'] as RenderSlotFn
  603. return (
  604. <Provider empty={() => <i>empty</i>}>
  605. {() => renderSlot('k.session', {})}
  606. </Provider>
  607. )
  608. },
  609. children: { 'k.session': SINGLE_SESSION },
  610. })
  611. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  612. expect(view.container.textContent).toBe('empty')
  613. act(() => { h.current.set('s1') })
  614. expect(view.container.textContent).toBe('s1')
  615. // Entries whose children are all root-scope get no provider seat.
  616. const h2 = makeHost()
  617. h2.declare('k.single', SINGLE_ROOT)
  618. const seen2: AnyProps[] = []
  619. h2.add('root', {
  620. component: (props: AnyProps) => { seen2.push(props); return null },
  621. children: { 'k.single': SINGLE_ROOT },
  622. })
  623. render(<>{createSlotRenderer().renderRoot(h2.host, {})}</>)
  624. expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
  625. })
  626. it('renders nothing for a strict session slot while no session is current', () => {
  627. // Strict session entries decline (render null) without a session; the
  628. // loud path is reserved for a missing root binding provider.
  629. const h = makeHost()
  630. h.declare('k.session', SINGLE_SESSION)
  631. h.add('k.session', { component: () => <b>x</b> })
  632. const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
  633. renderSlot => renderSlot('k.session', {}))
  634. expect(view.container.querySelector('b')).toBeNull()
  635. })
  636. it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
  637. const h = makeHost()
  638. h.declare('k.single', SINGLE_ROOT)
  639. const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
  640. let bump = () => {}
  641. h.add('k.single', {
  642. component: ({ useStore, actions }: {
  643. useStore: <S>(sel: (s: { n: number }) => S) => S
  644. actions: { inc: () => void }
  645. }) => {
  646. bump = actions.inc
  647. return <b>{useStore(s => s.n)}</b>
  648. },
  649. store: handle,
  650. })
  651. const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
  652. expect(view.container.textContent).toBe('0')
  653. act(() => { bump() })
  654. expect(view.container.textContent).toBe('1')
  655. })
  656. it('resolves session-slot stores per scope key: values survive a switch-away and back', () => {
  657. const h = makeHost()
  658. h.declare('k.session', SINGLE_SESSION)
  659. h.addSession('s1')
  660. h.addSession('s2')
  661. const handle = miniStore(() => ({ draft: '' }), { setDraft: (_s, text: string) => ({ draft: text }) })
  662. let setDraft: (text: string) => void = () => {}
  663. h.add('k.session', {
  664. component: ({ useStore, actions }: {
  665. useStore: <S>(sel: (s: { draft: string }) => S) => S
  666. actions: { setDraft: (text: string) => void }
  667. }) => {
  668. setDraft = actions.setDraft
  669. return <b>{useStore(s => s.draft) || '(blank)'}</b>
  670. },
  671. store: handle,
  672. })
  673. const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
  674. <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
  675. ))
  676. act(() => { h.current.set('s1') })
  677. act(() => { setDraft('draft-one') })
  678. expect(view.container.textContent).toBe('draft-one')
  679. act(() => { h.current.set('s2') })
  680. expect(view.container.textContent).toBe('(blank)') // distinct instance per session
  681. act(() => { h.current.set('s1') })
  682. expect(view.container.textContent).toBe('draft-one') // same scope key = same instance
  683. })
  684. })
  685. describe('inject: execution point, parameter derivation, cache granularity', () => {
  686. it('root inject runs once per entry with no arguments (no store declared)', () => {
  687. const h = makeHost()
  688. h.declare('k.single', SINGLE_ROOT)
  689. const inject = vi.fn(() => ({ tag: 'FROM-INJECT' }))
  690. h.add('k.single', { component: ({ tag }: { tag?: string }) => <b>{tag}</b>, inject })
  691. const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
  692. expect(view.container.textContent).toBe('FROM-INJECT')
  693. act(() => { h.add('k.single', { component: () => null }) }) // sibling bump re-renders the outlet
  694. expect(inject).toHaveBeenCalledTimes(1)
  695. expect(inject).toHaveBeenCalledWith()
  696. })
  697. it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
  698. const h = makeHost()
  699. h.declare('k.session', SINGLE_SESSION)
  700. h.addSession('s1')
  701. h.addSession('s2')
  702. const inject = vi.fn((sessionId: string) => ({ sid: sessionId }))
  703. h.add('k.session', {
  704. component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
  705. inject: inject,
  706. })
  707. const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
  708. <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
  709. ))
  710. act(() => { h.current.set('s1') })
  711. expect(view.container.textContent).toBe('s1')
  712. expect(inject).toHaveBeenCalledTimes(1)
  713. expect(inject).toHaveBeenLastCalledWith('s1')
  714. act(() => { h.current.set('s2') })
  715. expect(view.container.textContent).toBe('s2')
  716. expect(inject).toHaveBeenCalledTimes(2)
  717. act(() => { h.current.set('s1') }) // back: (entry x cell) cache hit
  718. expect(view.container.textContent).toBe('s1')
  719. expect(inject).toHaveBeenCalledTimes(2)
  720. })
  721. it('store-declaring entries get baked actions appended to the inject parameters', () => {
  722. const h = makeHost()
  723. h.declare('k.single', SINGLE_ROOT)
  724. h.declare('k.session', SINGLE_SESSION)
  725. h.addSession('s1')
  726. const handle = miniStore(() => ({ n: 0 }), { inc: s => ({ n: s.n + 1 }) })
  727. const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
  728. const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
  729. const seenRoot: AnyProps[] = []
  730. const seenSession: AnyProps[] = []
  731. h.add('k.single', {
  732. component: (props: object) => { seenRoot.push(props as AnyProps); return null },
  733. inject: rootInject,
  734. store: handle,
  735. })
  736. h.add('k.session', {
  737. component: (props: object) => { seenSession.push(props as AnyProps); return null },
  738. inject: sessionInject,
  739. store: handle,
  740. })
  741. mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
  742. {renderSlot('k.single', {})}
  743. <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
  744. </>)
  745. act(() => { h.current.set('s1') })
  746. // The inject-received actions are the same baked callbacks the component
  747. // gets as props.actions (one instance per entry x scope key).
  748. expect(rootInject).toHaveBeenCalledTimes(1)
  749. expect(seenRoot.at(-1)!['viaRoot']).toBe(seenRoot.at(-1)!['actions'])
  750. expect(sessionInject).toHaveBeenCalledTimes(1)
  751. expect(sessionInject.mock.calls[0]![0]).toBe('s1')
  752. expect(seenSession.at(-1)!['viaSession']).toBe(seenSession.at(-1)!['actions'])
  753. })
  754. it('contains a throwing inject factory to its own entry (runs inside the component body)', () => {
  755. const h = makeHost()
  756. h.declare('k.list', { kind: 'list', scope: 'root' })
  757. h.add('k.list', {
  758. component: () => <span>never</span>,
  759. options: { id: 'bad', order: 1 },
  760. inject: () => { throw new Error('inject boom') },
  761. })
  762. h.add('k.list', { component: () => <span>alive</span>, options: { id: 'ok', order: 2 } })
  763. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  764. const { view } = mountRoot(h, { 'k.list': { kind: 'list', scope: 'root' } },
  765. renderSlot => <main>{renderSlot('k.list', {})}</main>)
  766. spy.mockRestore()
  767. // The failing entry blacks out alone; the sibling and the tree above survive.
  768. expect(view.container.querySelector('main')).not.toBeNull()
  769. expect(view.container.textContent).toBe('alive')
  770. expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
  771. })
  772. it('merges kit, inject, and owner props with owner winning', () => {
  773. const h = makeHost()
  774. h.declare('k.single', SINGLE_ROOT)
  775. const seen: AnyProps[] = []
  776. h.add('k.single', {
  777. component: (props: object) => { seen.push(props as AnyProps); return null },
  778. inject: () => ({ fromInject: 'inject', shared: 'inject' }),
  779. })
  780. mountRoot(h, { 'k.single': SINGLE_ROOT },
  781. renderSlot => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
  782. const props = seen.at(-1)!
  783. expect(typeof props['useSessions']).toBe('function') // kit always present
  784. expect(typeof props['useWorkspaces']).toBe('function')
  785. expect(props['fromInject']).toBe('inject')
  786. expect(props['owner']).toBe('owner')
  787. expect(props['shared']).toBe('owner') // owner overrides inject
  788. })
  789. })