stale-authorization.client.spec.tsx 5.2 KB

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