scoped-slots.spec.tsx 40 KB

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