bind.spec.tsx 5.5 KB

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