deferred.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. // deferRegistration lifecycle: declaration-aware registration, HMR
  2. // re-registration, and — the failure contract — no subscription survives a
  3. // construction that throws synchronously (an already-occupied single slot).
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
  6. // Shares the merges declared by core.spec.ts (same program); reuse its keys.
  7. const HOLE = 'test.single' as const
  8. function declared(): SlotCore {
  9. const core = new SlotCore()
  10. core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
  11. return core
  12. }
  13. describe('deferRegistration', () => {
  14. it('registers immediately under an existing declaration and disposes cleanly', () => {
  15. const core = declared()
  16. const component = (): null => null
  17. const handle = deferRegistration(core, HOLE, component, () =>
  18. core.register({ name: HOLE } as never, component as never))
  19. expect(core.entries(HOLE)).toHaveLength(1)
  20. handle.dispose()
  21. expect(core.entries(HOLE)).toHaveLength(0)
  22. })
  23. it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
  24. const core = new SlotCore()
  25. const component = (): null => null
  26. const foreign = (): null => null
  27. const failures: unknown[] = []
  28. // Nothing is declared yet: the deferral just subscribes and waits.
  29. const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
  30. deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
  31. // The declaration lands with a foreign occupant racing in first: the
  32. // deferral's flush-time attempt fails, unsubscribes itself, and reports
  33. // through onFailure instead of throwing out of the flush.
  34. core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
  35. const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
  36. await Promise.resolve()
  37. expect(failures.map(String).join('')).toContain('already has a registration')
  38. // Unsubscribed: freeing the hole must not resurrect the loser.
  39. disposeForeign()
  40. await Promise.resolve()
  41. expect(core.entries(HOLE)).toHaveLength(0)
  42. })
  43. it('drops its subscription when the immediate registration throws', async () => {
  44. const core = declared()
  45. const foreign = (): null => null
  46. const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
  47. const component = (): null => null
  48. const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
  49. // The single hole is occupied: the immediate attempt throws out of the
  50. // constructor, and the caller never receives a handle to dispose.
  51. expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
  52. expect(register).toHaveBeenCalledOnce()
  53. // The subscription rolled back with it: freeing the hole flushes a
  54. // notification that must not resurrect the failed registration.
  55. disposeForeign()
  56. await Promise.resolve()
  57. expect(register).toHaveBeenCalledOnce()
  58. expect(core.entries(HOLE)).toHaveLength(0)
  59. })
  60. })
  61. describe('deferGroupRegistration', () => {
  62. const HOLES = ['test.single', 'test.grandchild'] as const
  63. function declaredPair(): SlotCore {
  64. const core = new SlotCore()
  65. core.register({
  66. name: 'root',
  67. children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
  68. } as never, (() => null) as never)
  69. return core
  70. }
  71. it('registers the whole group and disposes it as a unit', () => {
  72. const core = declaredPair()
  73. const component = (): null => null
  74. const group = deferGroupRegistration(core, HOLES, component, name =>
  75. core.register({ name } as never, component as never))
  76. for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
  77. group.dispose()
  78. for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
  79. })
  80. it('rolls the group back when construction fails partway', () => {
  81. const core = declaredPair()
  82. const component = (): null => null
  83. core.register({ name: HOLES[1] } as never, (() => null) as never)
  84. expect(() => deferGroupRegistration(core, HOLES, component, name =>
  85. core.register({ name } as never, component as never))).toThrow(/already has a registration/)
  86. // The first hole's registration and subscription rolled back with it.
  87. expect(core.entries(HOLES[0])).toHaveLength(0)
  88. })
  89. it('rolls the group back and re-raises loudly on a late conflict', async () => {
  90. const core = new SlotCore()
  91. const component = (): null => null
  92. const failures: unknown[] = []
  93. const onLoud = (reason: unknown): void => { failures.push(reason) }
  94. process.on('uncaughtException', onLoud)
  95. try {
  96. const group = deferGroupRegistration(core, HOLES, component, name =>
  97. core.register({ name } as never, component as never))
  98. // Declaration lands with a rival racing in ahead of the flush.
  99. core.register({
  100. name: 'root',
  101. children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
  102. } as never, (() => null) as never)
  103. core.register({ name: HOLES[0] } as never, (() => null) as never)
  104. core.register({ name: HOLES[1] } as never, (() => null) as never)
  105. await new Promise(resolve => setTimeout(resolve, 20))
  106. expect(failures.map(String).join('')).toContain('already has a registration')
  107. // No partial occupancy from the group's owner survives.
  108. for (const name of HOLES) {
  109. expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
  110. }
  111. group.dispose()
  112. } finally {
  113. process.off('uncaughtException', onLoud)
  114. }
  115. })
  116. })