email-tracking-indicator.test.tsx3.5 KBView on GitHub
/**
 * Regression test for the React #310 whole-app crash.
 *
 * EmailTrackingIndicator is mounted with openCount/clickCount === 0 for every sent message,
 * and those props come straight off the thread query (thread.tsx reads
 * getThreadData.trackingData). When the recipient opens the email the pixel fires, the thread
 * refetches, and the SAME mounted instance re-renders with openCount === 1. If any hook sits
 * below the "no activity" guard, that render runs more hooks than the previous one and React
 * tears down the whole route: "Rendered more hooks than during the previous render."
 *
 * These tests drive that exact prop transition — in both directions, since going quiet again
 * would raise the mirror-image #300.
 */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EmailTrackingIndicator } from '@/modules/threads/components/email-tracking-indicator';
import { render } from '@testing-library/react';
import React from 'react';

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, gcTime: 0 } },
});

const Wrapper = ({ children }: { children: React.ReactNode }) => (
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

const baseProps = {
  trackingId: 'track_abc123',
  firstOpenedAt: null,
  lastOpenedAt: null,
  firstClickedAt: null,
  openTrackingEnabled: true,
  linkTrackingEnabled: true,
};

describe('EmailTrackingIndicator hook stability', () => {
  // React logs the hook-order violation through console.error before rethrowing; the assertion
  // below is what proves the behaviour, so keep the noise out of the run.
  let consoleError: jest.SpyInstance;
  beforeEach(() => {
    consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
  });
  afterEach(() => {
    consoleError.mockRestore();
    queryClient.clear();
  });

  it('survives the first open landing on an already-mounted indicator (no activity -> opened)', () => {
    const { container, rerender } = render(
      <EmailTrackingIndicator {...baseProps} openCount={0} clickCount={0} />,
      { wrapper: Wrapper },
    );

    // Nothing to show yet: a freshly sent message that has not been opened.
    expect(container).toBeEmptyDOMElement();

    // The tracking pixel fires and the thread query refetches with a non-zero open count.
    expect(() =>
      rerender(
        <EmailTrackingIndicator
          {...baseProps}
          openCount={1}
          clickCount={0}
          lastOpenedAt={new Date('2026-08-18T06:07:51Z')}
        />,
      ),
    ).not.toThrow();

    expect(container.querySelector('button')).not.toBeNull();
  });

  it('survives the reverse transition (opened -> no activity)', () => {
    const { container, rerender } = render(
      <EmailTrackingIndicator {...baseProps} openCount={2} clickCount={1} />,
      { wrapper: Wrapper },
    );

    expect(container.querySelector('button')).not.toBeNull();

    expect(() =>
      rerender(<EmailTrackingIndicator {...baseProps} openCount={0} clickCount={0} />),
    ).not.toThrow();

    expect(container).toBeEmptyDOMElement();
  });

  it('survives tracking being switched off while the indicator is mounted', () => {
    const { rerender } = render(
      <EmailTrackingIndicator {...baseProps} openCount={3} clickCount={0} />,
      { wrapper: Wrapper },
    );

    expect(() =>
      rerender(
        <EmailTrackingIndicator
          {...baseProps}
          openCount={3}
          clickCount={0}
          openTrackingEnabled={false}
          linkTrackingEnabled={false}
        />,
      ),
    ).not.toThrow();
  });
});