clients-row-cells-memo.test.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { useState } from 'react';
  2. import { render, screen } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { describe, expect, it, vi } from 'vitest';
  5. import { ClientInboundChips, ClientRowActions } from '@/pages/clients/RowCells';
  6. import type { InboundOption } from '@/hooks/useClients';
  7. const PROTOCOL_COLORS = { vless: 'blue', trojan: 'volcano' };
  8. // Counts how often the cell reads the inbound map, which happens once per chip
  9. // per render. A traffic push re-renders the row, so if the cell is not memoised
  10. // this climbs every five seconds for every visible row.
  11. function countingInboundMap(source: Record<number, InboundOption>) {
  12. const reads = { count: 0 };
  13. const proxy = new Proxy(source, {
  14. get(target, key) {
  15. if (typeof key === 'string' && /^\d+$/.test(key)) reads.count += 1;
  16. return target[key as unknown as number];
  17. },
  18. });
  19. return { proxy, reads };
  20. }
  21. const INBOUNDS: Record<number, InboundOption> = {
  22. 1: { id: 1, tag: 'in-vless', remark: 'DE', protocol: 'vless' },
  23. 2: { id: 2, tag: 'in-trojan', remark: 'NL', protocol: 'trojan' },
  24. };
  25. function Harness({ children }: { children: (bump: () => void) => React.ReactNode }) {
  26. const [, setTick] = useState(0);
  27. return <>{children(() => setTick((n) => n + 1))}</>;
  28. }
  29. describe('clients table row cells', () => {
  30. it('does not re-render the inbound chips when the row re-renders with the same attachments', async () => {
  31. const { proxy, reads } = countingInboundMap(INBOUNDS);
  32. const ids = [1, 2];
  33. let bump: () => void = () => {};
  34. render(
  35. <Harness>
  36. {(doBump) => {
  37. bump = doBump;
  38. return (
  39. <ClientInboundChips
  40. ids={ids}
  41. inboundsById={proxy}
  42. protocolColors={PROTOCOL_COLORS}
  43. chipLimit={1}
  44. />
  45. );
  46. }}
  47. </Harness>,
  48. );
  49. const afterFirstRender = reads.count;
  50. expect(afterFirstRender).toBeGreaterThan(0);
  51. // Three simulated traffic pushes: the parent re-renders, the props do not change.
  52. for (let i = 0; i < 3; i++) bump();
  53. await Promise.resolve();
  54. expect(reads.count).toBe(afterFirstRender);
  55. });
  56. it('re-renders the chips when the attachments actually change', async () => {
  57. const { proxy, reads } = countingInboundMap(INBOUNDS);
  58. function Swapper() {
  59. const [ids, setIds] = useState<number[]>([1]);
  60. return (
  61. <>
  62. <button type="button" onClick={() => setIds([1, 2])}>
  63. swap
  64. </button>
  65. <ClientInboundChips
  66. ids={ids}
  67. inboundsById={proxy}
  68. protocolColors={PROTOCOL_COLORS}
  69. chipLimit={1}
  70. />
  71. </>
  72. );
  73. }
  74. render(<Swapper />);
  75. const before = reads.count;
  76. await userEvent.click(screen.getByRole('button', { name: 'swap' }));
  77. expect(reads.count).toBeGreaterThan(before);
  78. });
  79. it('keeps the row actions wired to the right client across re-renders', async () => {
  80. const onShowQr = vi.fn();
  81. const onEdit = vi.fn();
  82. const noop = vi.fn();
  83. let bump: () => void = () => {};
  84. render(
  85. <Harness>
  86. {(doBump) => {
  87. bump = doBump;
  88. return (
  89. <ClientRowActions
  90. email="alice@x"
  91. onShowQr={onShowQr}
  92. onShowInfo={noop}
  93. onResetTraffic={noop}
  94. onEdit={onEdit}
  95. onDelete={noop}
  96. />
  97. );
  98. }}
  99. </Harness>,
  100. );
  101. for (let i = 0; i < 3; i++) bump();
  102. // Queried by position rather than label: the suite loads the real en-US
  103. // bundle, so the aria-labels are translated strings, not keys. Order is
  104. // QR, info, reset traffic, edit, delete.
  105. const buttons = screen.getAllByRole('button');
  106. expect(buttons).toHaveLength(5);
  107. await userEvent.click(buttons[0]);
  108. await userEvent.click(buttons[3]);
  109. expect(onShowQr).toHaveBeenCalledExactlyOnceWith('alice@x');
  110. expect(onEdit).toHaveBeenCalledExactlyOnceWith('alice@x');
  111. });
  112. });