/**
* Tests for ToastContext's post-unmount safety guards.
*
* Regression: a login response handler calling showToast AFTER the provider
* had already been unmounted by Vitest's afterEach scheduled a 3s setTimeout
* that fired during test teardown. The callback's setToasts then tried to
* schedule a React update against a torn-down jsdom, producing
* "window is not defined" as an uncaught exception.
*
* The provider now gates every setToasts call on an isMountedRef and
* re-checks inside the auto-dismiss setTimeout callback so stale async
* paths no-op instead of crashing.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, render, renderHook } from '@testing-library/react';
import { type ReactNode } from 'react';
import { ToastProvider, useToast } from '../../contexts/ToastContext';
function Wrapper({ children }: { children: ReactNode }) {
return {children};
}
describe('ToastContext post-unmount safety', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('does not crash when showToast is called after unmount', () => {
const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
// Capture the callbacks BEFORE unmount — a real stale-closure scenario.
// (Async handlers that kicked off before unmount keep their captured
// context value and will invoke this function after we tear down.)
const { showToast } = result.current;
unmount();
// Post-unmount invocation is now a no-op; must not throw.
expect(() => showToast('delayed error message', 'error')).not.toThrow();
});
it('does not invoke setToasts when the auto-dismiss timer fires after unmount', async () => {
vi.useFakeTimers();
const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
act(() => {
result.current.showToast('will outlive the provider', 'error');
});
// Unmount BEFORE the 3s timer fires — the unmount effect clears pending
// timers, but a belt-and-braces check inside the timer callback (for
// cases where the timer was scheduled post-unmount) must also hold.
unmount();
// Advance past the 3s auto-dismiss window. If the guard isn't in place
// this would throw "window is not defined" in a torn-down jsdom; we
// simulate by asserting no error propagates.
expect(() => {
vi.advanceTimersByTime(5000);
}).not.toThrow();
vi.useRealTimers();
});
it('post-unmount showPersistentToast and dismissToast are no-ops', () => {
const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });
const { showPersistentToast, dismissToast } = result.current;
unmount();
// Both must short-circuit rather than attempt setState on a dead tree.
expect(() => showPersistentToast('orphan', 'still here', 'info')).not.toThrow();
expect(() => dismissToast('orphan')).not.toThrow();
});
it('normal showToast flow still displays and auto-dismisses while mounted', () => {
vi.useFakeTimers();
const { result } = renderHook(() => useToast(), { wrapper: Wrapper });
act(() => {
result.current.showToast('mounted path works', 'success');
});
// No easy way to read toast DOM from the hook alone; assert the timer
// ran without throwing — that proves the isMountedRef guard didn't
// incorrectly short-circuit the mounted path.
expect(() => {
act(() => {
vi.advanceTimersByTime(3500);
});
}).not.toThrow();
vi.useRealTimers();
});
});
describe('ToastContext viewport suppression', () => {
// The kiosk layout flips setViewportSuppressed(true) on mount so the
// SpoolBuddy display stays free of main-app toasts (login flows, etc.).
// Verify the gate hides the visible viewport
// without affecting the underlying state machine.
function ViewportProbe() {
const { showToast, setViewportSuppressed } = useToast();
return (
<>