undoSendUnloadGuard.test.ts4.8 KBView on GitHub /**
* A held-back send is unsaved work — leaving the page must be guarded for as long as one exists.
*
* The guard used to live in the composer's own `useEffect`, but `proceedWithSend` closes the
* composer the instant the send is deferred, so the cleanup tore the listeners out while the 5s
* timer was still running. For the whole undo window nothing watched for teardown: closing the tab
* dropped the send with no request, no error and no telemetry.
*
* The guard therefore has to live where `pendingSend` lives — module scope — not in a component
* whose lifetime is shorter than the thing it protects.
*/
import { act, renderHook } from '@testing-library/react';
import { useUndoSend, performUndo } from '@/modules/drafting/hooks/use-undo-send';
jest.mock('sonner', () => ({
toast: Object.assign(jest.fn(), { dismiss: jest.fn(), success: jest.fn(), error: jest.fn() }),
}));
const mockSetTeardown = jest.fn();
jest.mock('@/providers/query-provider', () => ({
setTeardownNextRequest: (opts: { keepalive: boolean }) => mockSetTeardown(opts),
}));
const SETTINGS = { settings: { undoSendEnabled: true } } as never;
const flush = () => act(async () => { await Promise.resolve(); await Promise.resolve(); });
/** Fire a real cancelable beforeunload and report whether anything guarded it. */
const unloadIsGuarded = (): boolean => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
};
describe('undo-send — a pending send guards page teardown', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('does not guard when nothing is pending', () => {
expect(unloadIsGuarded()).toBe(false);
});
it('guards while a send is held — with no attachments', async () => {
const { result } = renderHook(() => useUndoSend());
act(() => {
result.current.startUndoableSend({
sendFn: () => Promise.resolve({ messageId: 'm1' }),
settings: SETTINGS,
emailData: {
to: ['<email>'],
subject: 'Q4 pricing',
message: '<p>numbers</p>',
attachments: [],
},
});
});
expect(unloadIsGuarded()).toBe(true);
// …and stops guarding once the send is committed.
act(() => {
jest.advanceTimersByTime(5_000);
});
await flush();
expect(unloadIsGuarded()).toBe(false);
});
it('stops guarding after the send is undone', async () => {
const { result } = renderHook(() => useUndoSend());
act(() => {
result.current.startUndoableSend({
sendFn: () => Promise.resolve({ messageId: 'm1' }),
settings: SETTINGS,
});
});
expect(unloadIsGuarded()).toBe(true);
act(() => {
performUndo();
});
expect(unloadIsGuarded()).toBe(false);
});
it('still dispatches the send if the user leaves anyway', async () => {
const { result } = renderHook(() => useUndoSend());
const sendFn = jest.fn(() => Promise.resolve({ messageId: 'm1' }));
const onSendComplete = jest.fn();
act(() => {
result.current.startUndoableSend({
sendFn,
settings: SETTINGS,
onSendComplete,
emailData: {
to: ['<email>'],
subject: 'Q4 pricing',
message: '<p>numbers</p>',
attachments: [],
},
});
});
// They confirmed "leave" — the browser then fires pagehide.
act(() => {
window.dispatchEvent(new Event('pagehide'));
});
await flush();
expect(sendFn).toHaveBeenCalledTimes(1);
expect(onSendComplete).toHaveBeenCalledWith({ messageId: 'm1' });
// Teardown is signalled either way — that is what skips the session gate, and awaiting
// anything while the page unloads is how a send gets lost. keepalive is the separate,
// size-limited part, available here because there are no attachments.
expect(mockSetTeardown).toHaveBeenCalledWith({ keepalive: true });
});
it('does not use keepalive when the send carries attachments', async () => {
const { result } = renderHook(() => useUndoSend());
act(() => {
result.current.startUndoableSend({
sendFn: () => Promise.resolve({ messageId: 'm1' }),
settings: SETTINGS,
emailData: {
to: ['<email>'],
subject: 'deck',
message: '<p>attached</p>',
attachments: [new File(['x'], 'deck.pdf', { type: 'application/pdf' })],
},
});
});
act(() => {
window.dispatchEvent(new Event('pagehide'));
});
await flush();
// Still a teardown commit — the session gate must be skipped — but too large for keepalive.
expect(mockSetTeardown).toHaveBeenCalledWith({ keepalive: false });
});
});