bind.spec.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. // @vitest-environment jsdom
  2. import { StrictMode } from 'react'
  3. import { describe, expect, it, vi } from 'vitest'
  4. import { act, render } from '@testing-library/react'
  5. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  6. import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
  7. // Keep equality local: this suite asserts the eq parameter contract without
  8. // adding a reverse dependency from web-react to runtime.
  9. const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
  10. Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.is(a[k], b[k]))
  11. interface Snap { a: number; b: number }
  12. /** Hand-rolled observable source so subscription counting is exact. */
  13. function makeSource(initial: Snap) {
  14. let state = initial
  15. const listeners = new Set<() => void>()
  16. let subscribeCalls = 0
  17. const source: ObservableSnapshot<Snap> = {
  18. getSnapshot: () => state,
  19. subscribe: (fn) => {
  20. subscribeCalls += 1
  21. listeners.add(fn)
  22. return () => { listeners.delete(fn) }
  23. },
  24. }
  25. return {
  26. source,
  27. set: (next: Snap) => {
  28. state = next
  29. for (const fn of [...listeners]) fn()
  30. },
  31. stats: { get subscribeCalls() { return subscribeCalls }, get active() { return listeners.size } },
  32. }
  33. }
  34. function Harness<S>({ useSelector, sel, eq, probe }: {
  35. useSelector: SnapshotSelectorHook<Snap>
  36. sel: (s: Snap) => S
  37. eq?: (a: S, b: S) => boolean
  38. probe: { renders: number; value?: S | undefined }
  39. }) {
  40. probe.renders += 1
  41. probe.value = useSelector(sel, eq)
  42. return null
  43. }
  44. describe('bindSnapshotSelector', () => {
  45. it('re-renders on selected change and bails out when the slice is equal', () => {
  46. const { source, set } = makeSource({ a: 1, b: 10 })
  47. const useSelector = bindSnapshotSelector(source)
  48. const probe = { renders: 0, value: undefined as number | undefined }
  49. render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
  50. expect(probe.value).toBe(1)
  51. const before = probe.renders
  52. act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
  53. expect(probe.renders).toBe(before)
  54. act(() => { set({ a: 2, b: 11 }) })
  55. expect(probe.renders).toBe(before + 1)
  56. expect(probe.value).toBe(2)
  57. })
  58. it('supports custom equality for object slices', () => {
  59. const { source, set } = makeSource({ a: 1, b: 10 })
  60. const useSelector = bindSnapshotSelector(source)
  61. const probe = { renders: 0, value: undefined as { a: number } | undefined }
  62. render(<Harness useSelector={useSelector} sel={s => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
  63. const before = probe.renders
  64. act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
  65. expect(probe.renders).toBe(before)
  66. act(() => { set({ a: 5, b: 99 }) })
  67. expect(probe.renders).toBe(before + 1)
  68. expect(probe.value).toEqual({ a: 5 })
  69. })
  70. it('does not resubscribe across re-renders of the same component', () => {
  71. const { source, set, stats } = makeSource({ a: 1, b: 10 })
  72. const useSelector = bindSnapshotSelector(source)
  73. const probe = { renders: 0, value: undefined as number | undefined }
  74. const { rerender } = render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
  75. const after = stats.subscribeCalls
  76. rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
  77. act(() => { set({ a: 2, b: 10 }) })
  78. rerender(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
  79. expect(stats.subscribeCalls).toBe(after)
  80. })
  81. it('is StrictMode-safe and cleans up subscriptions on unmount', () => {
  82. const { source, stats } = makeSource({ a: 1, b: 10 })
  83. const useSelector = bindSnapshotSelector(source)
  84. const probe = { renders: 0, value: undefined as number | undefined }
  85. const view = render(
  86. <StrictMode>
  87. <Harness useSelector={useSelector} sel={s => s.a} probe={probe} />
  88. </StrictMode>,
  89. )
  90. expect(probe.value).toBe(1)
  91. view.unmount()
  92. expect(stats.active).toBe(0)
  93. })
  94. it('binds method-style sources without losing this', () => {
  95. class MethodSource implements ObservableSnapshot<Snap> {
  96. private state: Snap = { a: 7, b: 0 }
  97. private listeners = new Set<() => void>()
  98. getSnapshot(): Snap { return this.state }
  99. subscribe(fn: () => void): () => void {
  100. this.listeners.add(fn)
  101. return () => { this.listeners.delete(fn) }
  102. }
  103. }
  104. const useSelector = bindSnapshotSelector(new MethodSource())
  105. const probe = { renders: 0, value: undefined as number | undefined }
  106. render(<Harness useSelector={useSelector} sel={s => s.a} probe={probe} />)
  107. expect(probe.value).toBe(7)
  108. })
  109. it('memoizes the selector result against getSnapshot spam', () => {
  110. const { source } = makeSource({ a: 1, b: 10 })
  111. const sel = vi.fn((s: Snap) => s.a)
  112. const useSelector = bindSnapshotSelector(source)
  113. const probe = { renders: 0, value: undefined as number | undefined }
  114. const { rerender } = render(<Harness useSelector={useSelector} sel={sel} probe={probe} />)
  115. const calls = sel.mock.calls.length
  116. rerender(<Harness useSelector={useSelector} sel={sel} probe={probe} />)
  117. // Same snapshot + same selector reference: no recompute beyond bookkeeping.
  118. expect(sel.mock.calls.length).toBeLessThanOrEqual(calls + 1)
  119. })
  120. })