registry.client.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897
  1. /**
  2. * SlotRegistry terminal-design account:
  3. * built-in 'root', the three load-time throws (duplicate declaration /
  4. * undeclared contribution / cross-scope store handle), the renderer installation
  5. * contract (double install / not installed / non-root key), store instance
  6. * resolution and lifecycle on the ledger axis, and the entry-unload cascade.
  7. */
  8. import { Context } from '@deepseek-ai/cordis'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import type { FC } from 'react'
  11. import { StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
  12. import type { ScopedStandardSourceBinding, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
  13. import { SlotRegistry } from '../src/client/registry.ts'
  14. // Test-only slot keys (merged so the typed entries/spec faces accept them).
  15. declare module '@deepseek-ai/dsh-client-ui-slots' {
  16. interface SlotMap {
  17. 't.host': { kind: 'single'; scope: 'root' }
  18. 't.maybe': { kind: 'single'; scope: 'session-maybe' }
  19. 't.panel': { kind: 'single'; scope: 'session' }
  20. 't.rows': { kind: 'list'; scope: 'root' }
  21. }
  22. }
  23. const C: FC<object> = () => null
  24. /**
  25. * Register/install/renderSlot through a type-erased view: the typed register
  26. * face rides wave-1 ui-slots types (red until that wave lands); the runtime
  27. * semantics under test are final.
  28. */
  29. interface ErasedService {
  30. register(options: object, component: unknown): () => void
  31. registerFactory(options: object, component: unknown): () => void
  32. inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
  33. install(renderer: object): void
  34. renderSlot(key: string, owner: object): unknown
  35. }
  36. interface Bench {
  37. ctx: Context
  38. svc: SlotRegistry
  39. erased: ErasedService
  40. }
  41. async function boot(): Promise<Bench> {
  42. const ctx = new Context()
  43. const fiber = ctx.plugin(SlotRegistry)
  44. await fiber
  45. // Service accessor (ctx.get reads the reflect store, which Service-class
  46. // plugins do not write; the accessor is the product path).
  47. const svc = ctx.slots
  48. return { ctx, svc, erased: svc as unknown as ErasedService }
  49. }
  50. /** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
  51. interface FakeInstance {
  52. getSnapshot: () => undefined
  53. subscribe: () => () => void
  54. actions: Record<string, never>
  55. clearPersisted: ReturnType<typeof vi.fn>
  56. }
  57. /** Fake store handle factory (create-count and clearPersisted observable). */
  58. function fakeHandle(persist?: string) {
  59. const created: FakeInstance[] = []
  60. const handle = {
  61. spec: {
  62. init: () => undefined,
  63. actions: {},
  64. ...(persist === undefined ? {} : { persist }),
  65. },
  66. create: vi.fn((_scopeKey?: string): FakeInstance => {
  67. const instance: FakeInstance = {
  68. getSnapshot: () => undefined, subscribe: () => () => undefined,
  69. actions: {}, clearPersisted: vi.fn(),
  70. }
  71. created.push(instance)
  72. return instance
  73. }),
  74. }
  75. return { handle, created }
  76. }
  77. /**
  78. * Install a capturing renderer, occupy 'root' (declaring `children` in the
  79. * same call — 'root' is single, so the one occupant is also the declarer),
  80. * and pull the host face out through renderSlot('root').
  81. */
  82. function captureHost(bench: Bench, children?: object): SlotRendererHost {
  83. let host: SlotRendererHost | undefined
  84. bench.erased.install({
  85. renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
  86. })
  87. bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
  88. bench.erased.renderSlot('root', {})
  89. if (host === undefined) throw new Error('renderer never received the host')
  90. return host
  91. }
  92. function scopedBinding(_ctx: Context, key: string) {
  93. const ctx = new Context()
  94. const binding: ScopedStandardSourceBinding = {
  95. key,
  96. ctx,
  97. hooks: {},
  98. keyedHooks: {},
  99. props: {},
  100. }
  101. return { binding, fiber: ctx.fiber }
  102. }
  103. describe('Factory definition ledger', () => {
  104. it('publishes one definition, rejects duplicates, and collapses its children on disposal', async () => {
  105. const bench = await boot()
  106. const host = captureHost(bench)
  107. const changed = vi.fn()
  108. host.subscribeFactory('test.factory', changed)
  109. const dispose = bench.erased.registerFactory({
  110. name: 'test.factory',
  111. scope: 'root',
  112. children: { 't.host': { kind: 'single', scope: 'root' } },
  113. }, C)
  114. const definition = host.factoryOf('test.factory')
  115. expect(definition).toMatchObject({ name: 'test.factory', scope: 'root' })
  116. expect(bench.svc.spec('t.host')).toEqual({ kind: 'single', scope: 'root' })
  117. const disposeChild = bench.erased.register({ name: 't.host' }, C)
  118. const child = host.entriesOf('t.host')[0]
  119. expect(bench.svc.snapshot('factory:test.factory')).toMatchObject([{
  120. type: 'factory',
  121. name: 'test.factory',
  122. scope: 'root',
  123. children: [{ type: 'slot', name: 't.host' }],
  124. }])
  125. expect(bench.svc.snapshot().some(node => node.type === 'slot' && node.name === 't.host')).toBe(false)
  126. expect(bench.svc.snapshot('factory:missing')).toEqual([])
  127. const releaseDefinition = host.retainFactoryOccurrence(definition!, {})
  128. releaseDefinition()
  129. releaseDefinition()
  130. expect(() => bench.erased.registerFactory({ name: 'test.factory', scope: 'root' }, C))
  131. .toThrow(/already has a definition/)
  132. dispose()
  133. dispose()
  134. await Promise.resolve()
  135. expect(changed).toHaveBeenCalledTimes(2)
  136. expect(host.factoryOf('test.factory')).toBeUndefined()
  137. expect(bench.svc.spec('t.host')).toBeUndefined()
  138. expect(host.isLive(child as never)).toBe(false)
  139. disposeChild()
  140. expect(() => bench.erased.registerFactory({ name: 'test.factory', scope: 'root' }, C)).not.toThrow()
  141. })
  142. it('keeps committed exclusive stores across effect replay and releases scoped instances', async () => {
  143. const bench = await boot()
  144. const host = captureHost(bench)
  145. const handles: ReturnType<typeof fakeHandle>[] = []
  146. const factory = vi.fn(() => {
  147. const handle = fakeHandle()
  148. handles.push(handle)
  149. return handle.handle
  150. })
  151. const dispose = bench.erased.registerFactory({
  152. name: 'test.scoped-factory', scope: 'session-maybe', store: factory,
  153. }, C)
  154. const definition = host.factoryOf('test.scoped-factory')!
  155. const scope = scopedBinding(bench.ctx, 's1')
  156. const firstOccurrence = {}
  157. const secondOccurrence = {}
  158. expect(() => host.factoryStoreOf(definition, undefined, firstOccurrence))
  159. .toThrow('session-maybe factory store resolution requires a session id')
  160. const first = host.factoryStoreOf(definition, scope.binding, firstOccurrence)
  161. const repeated = host.factoryStoreOf(definition, scope.binding, firstOccurrence)
  162. const second = host.factoryStoreOf(definition, scope.binding, secondOccurrence)
  163. const releaseFirst = host.retainFactoryOccurrence(definition, firstOccurrence)
  164. const releaseFirstAgain = host.retainFactoryOccurrence(definition, firstOccurrence)
  165. const releaseSecond = host.retainFactoryOccurrence(definition, secondOccurrence)
  166. expect(repeated).toBe(first)
  167. expect(second).not.toBe(first)
  168. expect(factory).toHaveBeenCalledTimes(2)
  169. expect(handles[0]?.created).toHaveLength(1)
  170. expect(handles[0]?.handle.create).toHaveBeenCalledWith('s1')
  171. expect(handles[1]?.handle.create).toHaveBeenCalledWith('s1')
  172. releaseFirst()
  173. releaseFirst()
  174. releaseFirstAgain()
  175. const releaseReplay = host.retainFactoryOccurrence(definition, firstOccurrence)
  176. expect(host.factoryStoreOf(definition, scope.binding, firstOccurrence)).toBe(first)
  177. expect(factory).toHaveBeenCalledTimes(2)
  178. releaseReplay()
  179. await scope.fiber.dispose()
  180. expect(handles[0]?.created[0]?.clearPersisted).not.toHaveBeenCalled()
  181. expect(handles[1]?.created[0]?.clearPersisted).not.toHaveBeenCalled()
  182. const replacement = scopedBinding(bench.ctx, 's1')
  183. const recreated = host.factoryStoreOf(definition, replacement.binding, secondOccurrence)
  184. expect(recreated).not.toBe(second)
  185. await replacement.fiber.dispose()
  186. expect(handles[1]?.created[1]?.clearPersisted).not.toHaveBeenCalled()
  187. releaseSecond()
  188. dispose()
  189. expect(() => host.factoryStoreOf(definition, undefined, {})).toThrow(StaleAuthorizationError)
  190. const staleRelease = host.retainFactoryOccurrence(definition, {})
  191. expect(() => { staleRelease() }).not.toThrow()
  192. })
  193. it('rejects persistence on an exclusive Factory Store', async () => {
  194. const bench = await boot()
  195. const host = captureHost(bench)
  196. const persistent = fakeHandle('factory.persist')
  197. bench.erased.registerFactory({
  198. name: 'test.persistent-factory', scope: 'root', store: () => persistent.handle,
  199. }, C)
  200. const definition = host.factoryOf('test.persistent-factory')!
  201. expect(() => host.factoryStoreOf(definition, undefined, {}))
  202. .toThrow(/exclusive store.*cannot declare persistence/)
  203. })
  204. it('shares a Factory store handle on the ordinary optional-scope axis', async () => {
  205. const bench = await boot()
  206. const host = captureHost(bench)
  207. const { handle } = fakeHandle()
  208. bench.erased.registerFactory({ name: 'test.shared-factory', scope: 'session-maybe', store: handle }, C)
  209. const definition = host.factoryOf('test.shared-factory')!
  210. const scope = scopedBinding(bench.ctx, 's1')
  211. expect(() => host.factoryStoreOf(definition, undefined, {}))
  212. .toThrow('session-maybe store resolution requires a session id')
  213. const first = host.factoryStoreOf(definition, scope.binding, {})
  214. const second = host.factoryStoreOf(definition, scope.binding, {})
  215. const scoped = host.factoryStoreOf(definition, scope.binding, {})
  216. expect(first).toBe(second)
  217. expect(scoped).toBe(first)
  218. expect(handle.create).toHaveBeenCalledOnce()
  219. expect(handle.create).toHaveBeenCalledWith('s1')
  220. await scope.fiber.dispose()
  221. })
  222. })
  223. describe("built-in 'root'", () => {
  224. it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
  225. const bench = await boot()
  226. expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
  227. expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
  228. expect(bench.svc.entries('root')).toHaveLength(1)
  229. })
  230. it('rejects a second declaration of root, attributing the built-in row', async () => {
  231. const bench = await boot()
  232. expect(() => bench.erased.register({
  233. name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
  234. }, C)).toThrow(/already declared.*built-in/)
  235. })
  236. })
  237. describe('load-time validation', () => {
  238. it('throws on contributing into an undeclared slot', async () => {
  239. const bench = await boot()
  240. expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
  241. })
  242. it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
  243. const bench = await boot()
  244. bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
  245. bench.erased.register({
  246. name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
  247. }, C)
  248. expect(() => bench.erased.register({
  249. name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
  250. }, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
  251. })
  252. it('throws when one store handle is bound to two scopes', async () => {
  253. const bench = await boot()
  254. bench.erased.register({
  255. name: 'root',
  256. children: {
  257. 't.host': { kind: 'single', scope: 'root' },
  258. 't.panel': { kind: 'single', scope: 'session' },
  259. },
  260. }, C)
  261. const { handle } = fakeHandle()
  262. bench.erased.register({ name: 't.host', store: handle }, C)
  263. expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
  264. .toThrow(/one handle, one scope/)
  265. })
  266. it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
  267. const bench = await boot()
  268. bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
  269. expect(() => bench.erased.register({
  270. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  271. }, C)).toThrow(/already has a registration/)
  272. // The failing call's declaration must not have landed.
  273. expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
  274. })
  275. })
  276. describe('declaration injection', () => {
  277. it('activates immediately and ignores ordinary entry mutations', async () => {
  278. const bench = await boot()
  279. bench.erased.register({
  280. name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
  281. }, C)
  282. const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
  283. const dispose = bench.erased.inject('t.rows', setup)
  284. expect(setup).toHaveBeenCalledOnce()
  285. bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
  286. await Promise.resolve()
  287. expect(setup).toHaveBeenCalledOnce()
  288. dispose()
  289. expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
  290. })
  291. it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
  292. const bench = await boot()
  293. const cleanup = vi.fn()
  294. const setup = vi.fn(() => {
  295. const unregister = bench.erased.register({ name: 't.host' }, C)
  296. return () => { unregister(); cleanup() }
  297. })
  298. bench.erased.inject('t.host', setup)
  299. expect(setup).not.toHaveBeenCalled()
  300. const disposeFrame = bench.erased.register({
  301. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  302. }, C)
  303. await Promise.resolve()
  304. expect(setup).toHaveBeenCalledOnce()
  305. expect(bench.svc.entries('t.host')).toHaveLength(1)
  306. disposeFrame()
  307. await Promise.resolve()
  308. expect(cleanup).toHaveBeenCalledOnce()
  309. expect(bench.svc.entries('t.host')).toHaveLength(0)
  310. bench.erased.register({
  311. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  312. }, C)
  313. await Promise.resolve()
  314. expect(setup).toHaveBeenCalledTimes(2)
  315. expect(bench.svc.entries('t.host')).toHaveLength(1)
  316. })
  317. it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
  318. const bench = await boot()
  319. const firstFrame = bench.erased.register({
  320. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  321. }, C)
  322. const cleanup = vi.fn()
  323. const setup = vi.fn(() => {
  324. const unregister = bench.erased.register({ name: 't.host' }, C)
  325. return () => { unregister(); cleanup() }
  326. })
  327. bench.erased.inject('t.host', setup)
  328. firstFrame()
  329. bench.erased.register({
  330. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  331. }, C)
  332. await Promise.resolve()
  333. expect(cleanup).toHaveBeenCalledOnce()
  334. expect(setup).toHaveBeenCalledTimes(2)
  335. expect(bench.svc.entries('t.host')).toHaveLength(1)
  336. })
  337. it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
  338. const active = await boot()
  339. active.erased.register({
  340. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  341. }, C)
  342. const activeFiber = active.ctx.plugin({
  343. name: 'active-injection',
  344. inject: ['slots'],
  345. apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
  346. })
  347. await activeFiber.await()
  348. expect(active.svc.entries('t.host')).toHaveLength(1)
  349. await activeFiber.dispose()
  350. expect(active.svc.entries('t.host')).toHaveLength(0)
  351. const waiting = await boot()
  352. const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
  353. const waitingFiber = waiting.ctx.plugin({
  354. name: 'waiting-injection',
  355. inject: ['slots'],
  356. apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
  357. })
  358. await waitingFiber.await()
  359. await waitingFiber.dispose()
  360. waiting.erased.register({
  361. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  362. }, C)
  363. await Promise.resolve()
  364. expect(setup).not.toHaveBeenCalled()
  365. })
  366. it('rolls back earlier yielded registrations when generator setup fails', async () => {
  367. const bench = await boot()
  368. bench.erased.register({
  369. name: 'root',
  370. children: {
  371. 't.host': { kind: 'single', scope: 'root' },
  372. 't.rows': { kind: 'list', scope: 'root' },
  373. },
  374. }, C)
  375. bench.erased.register({ name: 't.host' }, C)
  376. expect(() => bench.erased.inject('t.rows', function* () {
  377. yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
  378. yield bench.erased.register({ name: 't.host' }, C)
  379. })).toThrow(/already has a registration/)
  380. expect(bench.svc.entries('t.rows')).toHaveLength(0)
  381. })
  382. it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
  383. const bench = await boot()
  384. const failures: unknown[] = []
  385. const onLoud = (error: unknown): void => { failures.push(error) }
  386. process.on('uncaughtException', onLoud)
  387. try {
  388. const setup = vi.fn(function* () {
  389. yield bench.erased.register({ name: 't.host' }, C)
  390. throw null
  391. })
  392. bench.erased.inject('t.host', setup)
  393. const later = vi.fn(() => () => undefined)
  394. bench.erased.inject('t.host', later)
  395. const disposeFrame = bench.erased.register({
  396. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  397. }, C)
  398. await new Promise(resolve => setTimeout(resolve, 20))
  399. expect(failures).toHaveLength(1)
  400. expect(failures[0]).toBeInstanceOf(Error)
  401. expect(String(failures[0])).toContain('null')
  402. expect(later).toHaveBeenCalledOnce()
  403. disposeFrame()
  404. bench.erased.register({
  405. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  406. }, C)
  407. expect(setup).toHaveBeenCalledOnce()
  408. } finally {
  409. process.off('uncaughtException', onLoud)
  410. }
  411. })
  412. it('skips a stopped controller retained by the current declaration snapshot', async () => {
  413. const bench = await boot()
  414. let stopLater = (): void => {}
  415. const first = vi.fn(() => {
  416. stopLater()
  417. return () => undefined
  418. })
  419. const later = vi.fn(() => () => undefined)
  420. bench.erased.inject('t.host', first)
  421. stopLater = bench.erased.inject('t.host', later)
  422. bench.erased.register({
  423. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  424. }, C)
  425. expect(first).toHaveBeenCalledOnce()
  426. expect(later).not.toHaveBeenCalled()
  427. })
  428. it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
  429. const bench = await boot()
  430. const disposeFrame = bench.erased.register({
  431. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  432. }, C)
  433. let disposeReplacement = (): void => {}
  434. let replaced = false
  435. const first = vi.fn(() => () => {
  436. if (replaced) return
  437. replaced = true
  438. disposeReplacement = bench.erased.register({
  439. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  440. }, C)
  441. })
  442. const later = vi.fn(() => () => undefined)
  443. bench.erased.inject('t.host', first)
  444. bench.erased.inject('t.host', later)
  445. disposeFrame()
  446. expect(first).toHaveBeenCalledTimes(2)
  447. expect(later).toHaveBeenCalledTimes(2)
  448. expect(bench.svc.spec('t.host')).toBeDefined()
  449. disposeReplacement()
  450. })
  451. it('cancels a waiting injection when its contributor is already unloading', async () => {
  452. const bench = await boot()
  453. const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
  454. let release = (): void => {}
  455. const blocked = new Promise<void>((resolve) => { release = resolve })
  456. const pauseUnload = vi.fn(async () => { await blocked })
  457. const contributor = bench.ctx.plugin({
  458. name: 'unloading-injection',
  459. inject: ['slots'],
  460. apply: (ctx: Context) => {
  461. ctx.slots.inject('t.host', setup)
  462. ctx.effect(() => pauseUnload, 'pause contributor unload')
  463. },
  464. })
  465. await contributor.await()
  466. const disposing = contributor.dispose()
  467. expect(() => bench.erased.register({
  468. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  469. }, C)).not.toThrow()
  470. expect(setup).not.toHaveBeenCalled()
  471. await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
  472. release()
  473. await disposing
  474. })
  475. it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
  476. const bench = await boot()
  477. bench.erased.register({
  478. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  479. }, C)
  480. const componentA = (): null => null
  481. const componentB = (): null => null
  482. const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
  483. name,
  484. inject: ['slots'],
  485. apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
  486. })
  487. const first = mount('replacement-a', componentA)
  488. await first.await()
  489. expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
  490. await first.dispose()
  491. expect(bench.svc.entries('t.host')).toHaveLength(0)
  492. const second = mount('replacement-b', componentB)
  493. await second.await()
  494. expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
  495. })
  496. it('releases service-layer store state when the declaration collapses', async () => {
  497. const bench = await boot()
  498. let host: SlotRendererHost | undefined
  499. bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
  500. const disposeFrame = bench.erased.register({
  501. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  502. }, C)
  503. bench.erased.renderSlot('root', {})
  504. if (host === undefined) throw new Error('renderer never received the host')
  505. const { handle } = fakeHandle()
  506. bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
  507. const oldEntry = host.entriesOf('t.host')[0]
  508. expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
  509. disposeFrame()
  510. expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
  511. bench.erased.register({
  512. name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
  513. }, C)
  514. bench.erased.register({ name: 't.panel', store: handle }, C)
  515. const panelEntry = host.entriesOf('t.panel')[0]
  516. const scope = scopedBinding(bench.ctx, 's1')
  517. expect(host.storeOf(panelEntry as never, scope.binding)).toBeDefined()
  518. expect(handle.create).toHaveBeenLastCalledWith('s1')
  519. await scope.fiber.dispose()
  520. })
  521. })
  522. describe('renderer install seam', () => {
  523. it('throws on renderSlot before install (boot-order guidance)', async () => {
  524. const bench = await boot()
  525. expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
  526. })
  527. it('throws on double install', async () => {
  528. const bench = await boot()
  529. bench.erased.install({ renderRoot: () => null })
  530. expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
  531. })
  532. it('throws on any non-root key (single ctx-level entry)', async () => {
  533. const bench = await boot()
  534. bench.erased.install({ renderRoot: () => null })
  535. expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
  536. })
  537. it("throws on renderSlot('root') before any root registration", async () => {
  538. const bench = await boot()
  539. bench.erased.install({ renderRoot: () => null })
  540. expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
  541. })
  542. it('renders through the installed renderer and returns its product', async () => {
  543. const bench = await boot()
  544. const renderRoot = vi.fn(() => 'tree')
  545. bench.erased.install({ renderRoot })
  546. bench.erased.register({ name: 'root' }, C)
  547. expect(bench.erased.renderSlot('root', {})).toBe('tree')
  548. expect(renderRoot).toHaveBeenCalledTimes(1)
  549. })
  550. })
  551. describe('host face', () => {
  552. it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
  553. const bench = await boot()
  554. const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
  555. const dispose = bench.erased.register({ name: 't.host' }, C)
  556. const rootEntry = host.entriesOf('root')[0]
  557. expect(rootEntry).toBeDefined()
  558. expect(rootEntry?.component).toBe(C)
  559. expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
  560. expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
  561. const childEntry = host.entriesOf('t.host')[0]
  562. expect(host.isLive(childEntry as never)).toBe(true)
  563. dispose()
  564. expect(host.isLive(childEntry as never)).toBe(false)
  565. expect(host.entriesOf('t.host')).toHaveLength(0)
  566. })
  567. it('publishes and retracts domain-owned root standard sources atomically', async () => {
  568. const bench = await boot()
  569. const host = captureHost(bench)
  570. const source = { getSnapshot: () => 1, subscribe: () => () => undefined }
  571. const changed = vi.fn()
  572. host.root.subscribe(changed)
  573. const dispose = bench.svc.provideRoot({ hooks: { sessions: source }, props: { ready: true } })
  574. expect(host.root.getSnapshot()).toEqual({
  575. key: undefined,
  576. hooks: { sessions: source },
  577. keyedHooks: {},
  578. props: { ready: true },
  579. })
  580. expect(changed).toHaveBeenCalledOnce()
  581. dispose()
  582. expect(host.root.getSnapshot()).toEqual({ key: undefined, hooks: {}, keyedHooks: {}, props: {} })
  583. expect(changed).toHaveBeenCalledTimes(2)
  584. })
  585. it('rejects duplicate final root prop names without publishing a partial binding', async () => {
  586. const bench = await boot()
  587. const host = captureHost(bench)
  588. const source = { getSnapshot: () => 1, subscribe: () => () => undefined }
  589. bench.svc.provideRoot({ hooks: { feature: source } })
  590. const before = host.root.getSnapshot()
  591. expect(() => bench.svc.provideRoot({ hooks: { feature: source } }))
  592. .toThrow("duplicate root standard hook 'feature' at prop 'useFeature'")
  593. expect(() => bench.svc.provideRoot({ keyedHooks: { feature: () => source } }))
  594. .toThrow("duplicate root standard keyed hook 'feature' at prop 'useFeature'")
  595. expect(() => bench.svc.provideRoot({ props: { useFeature: true } }))
  596. .toThrow("duplicate root standard prop 'useFeature' at prop 'useFeature'")
  597. expect(host.root.getSnapshot()).toBe(before)
  598. })
  599. it('publishes scope-adapter install and disposal revisions', async () => {
  600. const bench = await boot()
  601. const host = captureHost(bench)
  602. const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
  603. const source = { getSnapshot: () => absent, subscribe: () => () => undefined }
  604. const adapter = {
  605. current: source,
  606. bindingSource: () => source,
  607. }
  608. const changed = vi.fn()
  609. host.scopeRevision.subscribe(changed)
  610. const owner = bench.ctx.plugin({
  611. name: 'session-scope-owner',
  612. inject: ['slots'],
  613. apply: (ctx: Context) => { ctx.slots.installScope('session', adapter) },
  614. })
  615. await owner.await()
  616. expect(host.scopeRevision.getSnapshot()).toBe(1)
  617. expect(changed).toHaveBeenCalledOnce()
  618. expect(host.scope('session')).toBe(adapter)
  619. expect(host.scope('session-maybe')).toBe(adapter)
  620. expect(() => { bench.svc.installScope('session', adapter) }).toThrow(/already has an adapter/)
  621. await owner.dispose()
  622. expect(host.scopeRevision.getSnapshot()).toBe(2)
  623. expect(changed).toHaveBeenCalledTimes(2)
  624. expect(host.scope('session')).toBeUndefined()
  625. })
  626. })
  627. describe('store instance axis', () => {
  628. /** Boot with 'root' occupied and the three test children declared. */
  629. async function storeBench() {
  630. const bench = await boot()
  631. const host = captureHost(bench, {
  632. 't.host': { kind: 'single', scope: 'root' },
  633. 't.maybe': { kind: 'single', scope: 'session-maybe' },
  634. 't.rows': { kind: 'list', scope: 'root' },
  635. 't.panel': { kind: 'single', scope: 'session' },
  636. })
  637. return { bench, host }
  638. }
  639. it('resolves one instance per (handle x root scope) shared across entries', async () => {
  640. const { bench, host } = await storeBench()
  641. const { handle } = fakeHandle()
  642. bench.erased.register({ name: 't.host', store: handle }, C)
  643. bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
  644. const [hostEntry] = host.entriesOf('t.host')
  645. const [rowEntry] = host.entriesOf('t.rows')
  646. const a = host.storeOf(hostEntry as never, undefined)
  647. const b = host.storeOf(rowEntry as never, undefined)
  648. expect(a).toBeDefined()
  649. expect(a).toBe(b) // shared handle, same scope key = same instance
  650. expect(handle.create).toHaveBeenCalledTimes(1)
  651. expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
  652. })
  653. it('resolves per-session instances keyed by session id, created with the scope key', async () => {
  654. const { bench, host } = await storeBench()
  655. const { handle } = fakeHandle()
  656. bench.erased.register({ name: 't.panel', store: handle }, C)
  657. const [entry] = host.entriesOf('t.panel')
  658. const scope1 = scopedBinding(bench.ctx, 's1')
  659. const scope2 = scopedBinding(bench.ctx, 's2')
  660. const s1 = host.storeOf(entry as never, scope1.binding)
  661. const s2 = host.storeOf(entry as never, scope2.binding)
  662. expect(s1).not.toBe(s2)
  663. expect(host.storeOf(entry as never, scope1.binding)).toBe(s1) // cached per key
  664. expect(handle.create).toHaveBeenCalledWith('s1')
  665. expect(handle.create).toHaveBeenCalledWith('s2')
  666. expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
  667. await Promise.all([scope1.fiber.dispose(), scope2.fiber.dispose()])
  668. })
  669. it('mints a fresh handle per register for the factory (exclusive) form', async () => {
  670. const { bench, host } = await storeBench()
  671. const factory = vi.fn(() => fakeHandle().handle)
  672. bench.erased.register({ name: 't.host', store: factory }, C)
  673. bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
  674. expect(factory).toHaveBeenCalledTimes(2)
  675. const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
  676. const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
  677. expect(a).not.toBe(b) // two mints, two instances
  678. })
  679. it('drops instances with the last holding entry and refuses stale resolution', async () => {
  680. const { bench, host } = await storeBench()
  681. const { handle } = fakeHandle()
  682. const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
  683. bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
  684. const rowEntry = host.entriesOf('t.rows')[0]
  685. const hostEntry = host.entriesOf('t.host')[0]
  686. const shared = host.storeOf(rowEntry as never, undefined)
  687. d1() // one holder left: record (and instance) survive
  688. expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
  689. expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
  690. // Note: dropping the row entry would sever the last reference; stale
  691. // resolution is covered through the cascade spec below.
  692. })
  693. it('drops a materialized per-session instance without clearing persistence', async () => {
  694. const { bench, host } = await storeBench()
  695. const { handle, created } = fakeHandle()
  696. bench.erased.register({ name: 't.panel', store: handle }, C)
  697. const [entry] = host.entriesOf('t.panel')
  698. const scope = scopedBinding(bench.ctx, 's1')
  699. const s1 = host.storeOf(entry as never, scope.binding)
  700. expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
  701. await scope.fiber.dispose()
  702. expect(created[0]?.clearPersisted).not.toHaveBeenCalled()
  703. const replacement = scopedBinding(bench.ctx, 's1')
  704. expect(host.storeOf(entry as never, replacement.binding)).not.toBe(s1)
  705. await replacement.fiber.dispose()
  706. })
  707. it('does not materialize scoped stores solely for scope release', async () => {
  708. const { bench } = await storeBench()
  709. const root = fakeHandle()
  710. const scoped = fakeHandle()
  711. bench.erased.register({ name: 't.host', store: root.handle }, C)
  712. bench.erased.register({ name: 't.panel', store: scoped.handle }, C)
  713. const scope = scopedBinding(bench.ctx, 's1')
  714. bench.svc.bindStoreScope(scope.binding)
  715. await scope.fiber.dispose()
  716. expect(root.handle.create).not.toHaveBeenCalled()
  717. expect(scoped.handle.create).not.toHaveBeenCalled()
  718. })
  719. it('leaves scoped Store cleanup with the newest Context generation', async () => {
  720. const { bench } = await storeBench()
  721. const { handle } = fakeHandle()
  722. bench.erased.register({ name: 't.panel', store: handle }, C)
  723. const first = scopedBinding(bench.ctx, 's1')
  724. const replacement = scopedBinding(bench.ctx, 's1')
  725. bench.svc.bindStoreScope(first.binding)
  726. bench.svc.bindStoreScope(first.binding)
  727. bench.svc.bindStoreScope(replacement.binding)
  728. await first.fiber.dispose()
  729. expect(handle.create).not.toHaveBeenCalled()
  730. await replacement.fiber.dispose()
  731. expect(handle.create).not.toHaveBeenCalled()
  732. })
  733. it('drops session-maybe state without clearing persistence and creates a fresh instance on reuse', async () => {
  734. const { bench, host } = await storeBench()
  735. const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
  736. const source = { getSnapshot: () => absent, subscribe: () => () => undefined }
  737. bench.svc.installScope('session', { current: source, bindingSource: () => source })
  738. const { handle, created } = fakeHandle()
  739. bench.erased.register({ name: 't.maybe', store: handle }, C)
  740. const [entry] = host.entriesOf('t.maybe')
  741. expect(() => host.storeOf(entry as never, undefined))
  742. .toThrow('session-maybe store resolution requires a session id')
  743. const scope = scopedBinding(bench.ctx, 's1')
  744. const before = host.storeOf(entry as never, scope.binding)
  745. await scope.fiber.dispose()
  746. expect(created[0]?.clearPersisted).not.toHaveBeenCalled()
  747. const replacement = scopedBinding(bench.ctx, 's1')
  748. const after = host.storeOf(entry as never, replacement.binding)
  749. expect(after).not.toBe(before)
  750. expect(handle.create).toHaveBeenLastCalledWith('s1')
  751. await replacement.fiber.dispose()
  752. })
  753. })
  754. describe('entry-unload cascade', () => {
  755. it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
  756. const bench = await boot()
  757. let host: SlotRendererHost | undefined
  758. bench.erased.install({
  759. renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
  760. })
  761. // The declarer here is NOT the root occupant: root stays occupied by a
  762. // separate entry so disposing the declarer only kills its children.
  763. const disposeRoot = bench.erased.register({ name: 'root' }, C)
  764. bench.erased.renderSlot('root', {})
  765. if (host === undefined) throw new Error('renderer never received the host')
  766. disposeRoot()
  767. const disposeDeclarer = bench.erased.register({
  768. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  769. }, C)
  770. bench.erased.register({ name: 't.host' }, C)
  771. const [childEntry] = host.entriesOf('t.host')
  772. expect(childEntry).toBeDefined()
  773. disposeDeclarer()
  774. expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
  775. expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
  776. expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
  777. expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
  778. // The freed key is re-declarable by a new entry (no residue).
  779. expect(() => bench.erased.register({
  780. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  781. }, C)).not.toThrow()
  782. })
  783. it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
  784. const bench = await boot()
  785. bench.erased.register({
  786. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  787. }, C)
  788. const fiber = bench.ctx.plugin({
  789. name: 'occupant',
  790. inject: ['slots'],
  791. apply: (pluginCtx: Context) => {
  792. ;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
  793. },
  794. })
  795. await fiber.await()
  796. expect(bench.svc.entries('t.host')).toHaveLength(1)
  797. await fiber.dispose()
  798. expect(bench.svc.entries('t.host')).toHaveLength(0)
  799. expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
  800. })
  801. it('disposer is idempotent (stale second call is a no-op)', async () => {
  802. const bench = await boot()
  803. const dispose = bench.erased.register({
  804. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  805. }, C)
  806. dispose()
  807. expect(() => { dispose() }).not.toThrow()
  808. expect(() => bench.erased.register({
  809. name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
  810. }, C)).not.toThrow()
  811. })
  812. })
  813. describe('event bridge', () => {
  814. it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
  815. const bench = await boot()
  816. const seen: string[] = []
  817. bench.ctx.on('slots/changed', (key) => { seen.push(key) })
  818. bench.erased.register({
  819. name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
  820. }, C)
  821. bench.erased.register({ name: 't.rows', id: 'a' }, C)
  822. expect(seen).toEqual(['root', 't.rows', 't.rows'])
  823. })
  824. })