registry.client.spec.ts 31 KB

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