bind.spec.tsx 5.0 KB

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