/** * 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 ( <>