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();
});
});