stale-authorization.client.spec.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. // @vitest-environment jsdom
  2. /**
  3. * A retained render binding dies with its entry. Re-registering the same key
  4. * creates a new binding rather than reviving the stale closure.
  5. */
  6. import { describe, expect, it } from 'vitest'
  7. import { act, render } from '@testing-library/react'
  8. import type { ReactNode } from 'react'
  9. import {
  10. StaleAuthorizationError, type SlotEntryDef, type SlotSpec, type StoredEntry,
  11. } from '@deepseek-ai/dsh-client-ui-slots'
  12. import type {
  13. RenderOpts, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
  14. } from '@deepseek-ai/dsh-client-ui-renderer/client'
  15. import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
  16. type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
  17. type DeclaredSpec = SlotSpec<SlotEntryDef>
  18. /** Ledger-shaped fake: add/dispose maintain the live set the way the runtime ledger does. */
  19. function makeHost() {
  20. const entries = new Map<string, StoredEntry[]>()
  21. const versions = new Map<string, number>()
  22. const subs = new Map<string, Set<() => void>>()
  23. const live = new Set<StoredEntry>()
  24. const absentBinding: StandardSourceBinding = {
  25. key: undefined,
  26. hooks: {},
  27. keyedHooks: {},
  28. props: {},
  29. }
  30. const bindingSource = {
  31. getSnapshot: () => absentBinding,
  32. subscribe: () => () => {},
  33. }
  34. const sessionAdapter: SlotScopeAdapter = {
  35. current: bindingSource,
  36. bindingSource: () => bindingSource,
  37. }
  38. const bump = (key: string) => {
  39. versions.set(key, (versions.get(key) ?? 0) + 1)
  40. for (const fn of [...(subs.get(key) ?? [])]) fn()
  41. }
  42. const host: SlotRendererHost = {
  43. subscribe: (key, fn) => {
  44. const set = subs.get(key) ?? new Set()
  45. set.add(fn)
  46. subs.set(key, set)
  47. return () => { set.delete(fn) }
  48. },
  49. getVersion: key => versions.get(key) ?? 0,
  50. entriesOf: key => entries.get(key) ?? [],
  51. // Single-kind everywhere and no crashes in this suite: the projection is
  52. // the raw view and crash reports never fire.
  53. entriesOfSlot: key => entries.get(key) ?? [],
  54. reportEntryError: () => {},
  55. reportFactoryError: () => {},
  56. specOf: () => ({ kind: 'single', scope: 'root' }),
  57. isLive: entry => live.has(entry),
  58. storeOf: () => undefined,
  59. factoryStoreOf: () => undefined,
  60. retainFactoryOccurrence: () => () => {},
  61. subscribeFactory: () => () => {},
  62. getFactoryVersion: () => 0,
  63. factoryOf: () => undefined,
  64. isFactoryLive: () => false,
  65. root: bindingSource,
  66. scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
  67. scope: () => sessionAdapter,
  68. }
  69. return {
  70. host,
  71. add: (key: string, entry: StoredEntry) => {
  72. entries.set(key, [...(entries.get(key) ?? []), entry])
  73. live.add(entry)
  74. bump(key)
  75. return () => {
  76. entries.set(key, (entries.get(key) ?? []).filter(e => e !== entry))
  77. live.delete(entry)
  78. bump(key)
  79. }
  80. },
  81. }
  82. }
  83. const CHILD: DeclaredSpec = { kind: 'single', scope: 'root' }
  84. /**
  85. * Mount a root entry that leaks its binding to the test, then render. The
  86. * returned dispose unmounts the view FIRST: an empty 'root' makes the live
  87. * root outlet rethrow its boot-order failure (fail-loud, covered in the
  88. * scoped-slots suite); the retained-closure scenario under test here is a
  89. * dead entry whose binding outlives the tree.
  90. */
  91. function mountCapturing(h: ReturnType<typeof makeHost>) {
  92. let captured: RenderSlotFn | undefined
  93. const entry: StoredEntry = {
  94. component: (props: { renderSlot: RenderSlotFn }) => {
  95. captured = props.renderSlot
  96. return null
  97. },
  98. options: {},
  99. children: { 'k.child': CHILD },
  100. }
  101. const disposeEntry = h.add('root', entry)
  102. const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  103. return {
  104. binding: captured!,
  105. entry,
  106. dispose: () => {
  107. view.unmount()
  108. disposeEntry()
  109. },
  110. }
  111. }
  112. describe('stale authorization', () => {
  113. it('a live binding renders; the same closure throws after its entry is disposed', () => {
  114. const h = makeHost()
  115. const { binding, dispose } = mountCapturing(h)
  116. expect(binding('k.child', {})).not.toBeUndefined() // live: returns an element
  117. act(() => { dispose() })
  118. expect(() => binding('k.child', {})).toThrow(StaleAuthorizationError)
  119. expect(() => binding('k.child', {})).toThrow(/disposed registration/)
  120. })
  121. it('stale check precedes the ownership check: a dead binding throws stale even for undeclared keys', () => {
  122. const h = makeHost()
  123. const { binding, dispose } = mountCapturing(h)
  124. act(() => { dispose() })
  125. // Were ownership checked first this would be SlotOwnershipError; the dead
  126. // entry must fail on liveness regardless of the key asked for.
  127. expect(() => binding('k.undeclared', {})).toThrow(StaleAuthorizationError)
  128. })
  129. it('HMR reload (same key, new entry) mints a fresh binding; the old one stays dead', () => {
  130. const h = makeHost()
  131. const first = mountCapturing(h)
  132. act(() => { first.dispose() })
  133. // Reload: a new entry object for the same slot key (new registration identity).
  134. let secondBinding: RenderSlotFn | undefined
  135. const secondEntry: StoredEntry = {
  136. component: (props: { renderSlot: RenderSlotFn }) => {
  137. secondBinding = props.renderSlot
  138. return null
  139. },
  140. options: {},
  141. children: { 'k.child': CHILD },
  142. }
  143. h.add('root', secondEntry)
  144. render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
  145. expect(secondBinding).toBeDefined()
  146. expect(secondBinding).not.toBe(first.binding) // new identity, no revival
  147. expect(secondBinding!('k.child', {})).not.toBeUndefined() // new binding is live
  148. expect(() => first.binding('k.child', {})).toThrow(StaleAuthorizationError) // old stays dead
  149. })
  150. })