scoped-slots.spec.tsx 31 KB

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