diff --git a/src/components/Toast/Toast.test.tsx b/src/components/Toast/Toast.test.tsx new file mode 100644 index 000000000..b606982b6 --- /dev/null +++ b/src/components/Toast/Toast.test.tsx @@ -0,0 +1,250 @@ +import { useEffect } from 'react'; +import { act, render } from '@testing-library/react'; +import { useToast } from '@/hooks/useToast'; +import { ClickUIProvider } from '@/providers'; +import { renderCUI } from '@/utils/test-utils'; +import { Toast } from '@/components/Toast'; +import type { ToastProviderProps } from '@/components/Toast'; + +// `ClickUIProvider` already nests `ToastProvider` and forwards `config.toast`, +// so we render directly into that to exercise the full +// `ToastProvider` -> `Toast` integration path (including provider-level +// defaults like `duration`). +const renderInToastProvider = ( + ui: React.ReactNode, + providerProps: Omit = {} +) => + render( + + {ui} + + ); + +describe('Toast', () => { + beforeEach(() => { + vi.useFakeTimers({ + // Don't fake `setImmediate` / `queueMicrotask`, otherwise React's + // scheduler / styled-components batching can hang. + toFake: ['setTimeout', 'clearTimeout', 'Date'], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('auto-closes after the configured duration', () => { + const onClose = vi.fn(); + renderCUI( + + ); + + expect(onClose).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(onClose).toHaveBeenCalledWith(false); + }); + + it('does not leak the auto-close timer past unmount (regression for radix-ui/primitives#3703)', () => { + const onClose = vi.fn(); + const { unmount } = renderCUI( + + ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onClose).not.toHaveBeenCalled(); + + unmount(); + + // Simulate the post-unmount + post-jsdom-teardown environment: any + // subsequent access to `document.activeElement` would normally throw + // `ReferenceError: document is not defined`. Here we stub the getter so + // that an access flips a boolean we can assert against. If Radix's + // internal close timer leaks past unmount (radix-ui/primitives#3703, + // still present in 1.2.17), `handleClose` will fire after unmount and + // dereference `document.activeElement`, tripping the stub. With this + // PR's fix, we own the timer ourselves and clear it from the unmount + // cleanup, so no Radix `handleClose` ever runs post-unmount. + const originalDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'activeElement' + ); + if (!originalDescriptor) { + throw new Error('Expected Document.prototype.activeElement descriptor'); + } + let leakedDocumentAccess = false; + Object.defineProperty(Document.prototype, 'activeElement', { + configurable: true, + get: () => { + leakedDocumentAccess = true; + return null; + }, + }); + try { + act(() => { + vi.advanceTimersByTime(60_000); + }); + } finally { + Object.defineProperty(Document.prototype, 'activeElement', originalDescriptor); + } + expect(leakedDocumentAccess).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not reset the auto-close timer when the parent re-renders with a new onClose identity', () => { + // Regression for: in ToastProvider, `onClose={onClose(id)}` produces a + // fresh function on every provider render. Without a stable callback ref + // inside Toast, every provider update (e.g. another toast appearing) + // would restart the countdown from the full duration, so a toast could + // be kept alive indefinitely by a frequently-rerendering parent. + const onClose1 = vi.fn(); + const onClose2 = vi.fn(); + const { rerender } = renderCUI( + + ); + + act(() => { + vi.advanceTimersByTime(600); + }); + expect(onClose1).not.toHaveBeenCalled(); + + rerender( + + ); + + // Total elapsed since mount = 1100ms > original duration of 1000ms. + // If the timer was reset on re-render, no onClose would have fired yet + // (only 500ms of a fresh 1000ms countdown would have elapsed). + act(() => { + vi.advanceTimersByTime(500); + }); + // The latest onClose must have been invoked exactly once. + expect(onClose2).toHaveBeenCalledTimes(1); + expect(onClose2).toHaveBeenCalledWith(false); + // And the original onClose -- which is no longer the prop -- must not + // have been invoked. + expect(onClose1).not.toHaveBeenCalled(); + }); + + it('does not auto-close when duration is Infinity', () => { + const onClose = vi.fn(); + renderCUI( + + ); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('falls back to the provider-level duration when a toast does not set its own', () => { + // Regression: before this PR, an unset `ToastProps.duration` let Radix's + // `ToastProvider duration` (the default for every toast) take effect. + // After moving the timer into click-ui's wrapper, we must keep the same + // resolution order: prop > provider > built-in default. + const onCreated = vi.fn(); + const TriggerWithSpy = () => { + const { createToast } = useToast(); + useEffect(() => { + createToast({ title: 'hello' }); + onCreated(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; + }; + + renderInToastProvider(, { duration: 2000 }); + expect(onCreated).toHaveBeenCalledTimes(1); + + // The toast was created with no explicit `duration`, so it should pick + // up the provider's 2000 ms and not the built-in 5000 ms default. + act(() => { + vi.advanceTimersByTime(1999); + }); + // The DOM should still have the toast. + expect(document.querySelector('[role="status"]')).not.toBeNull(); + + act(() => { + vi.advanceTimersByTime(2); + }); + // After 2001 ms total the provider-level deadline has elapsed and the + // toast should have been removed from the provider's state, which + // unmounts it from the DOM. + expect(document.querySelector('[role="status"]')).toBeNull(); + }); + + it('closes immediately on resume when the auto-close deadline elapsed during a pause', () => { + // Regression for: if `handlePause` runs after the auto-close deadline + // has already passed (event-loop delay / throttling beat Radix's + // close-timer to the punch), remaining time clamps to 0 and a naive + // `handleResume` -> `startCloseTimer(0)` would short-circuit, leaving + // the toast open forever. + const onClose = vi.fn(); + // Use renderCUI so we get a real `Viewport` (which is what Radix's + // window-blur listener dispatches `toast.viewportPause` against). + renderCUI( + + ); + + // Advance the wall clock past the deadline WITHOUT draining the fake + // timer queue, so the close-timer hasn't run yet but `Date.now()` + // reports an elapsed time greater than `duration`. + act(() => { + vi.setSystemTime(Date.now() + 500); + }); + expect(onClose).not.toHaveBeenCalled(); + + // Simulate Radix's viewport-level pause/resume. Radix's outer + // `
` wraps the actual viewport `
    `; the + // `VIEWPORT_PAUSE`/`VIEWPORT_RESUME` custom events are dispatched on the + // inner `
      ` (that's what `ToastImpl` listens on), not the wrapper. + const viewport = document.querySelector('[role="region"] > ol'); + if (!viewport) { + throw new Error('Expected Radix Toast viewport
        to be in the DOM'); + } + act(() => { + viewport.dispatchEvent(new CustomEvent('toast.viewportPause')); + }); + // Pause cleared the timer, but the deadline already elapsed -> remaining = 0. + act(() => { + viewport.dispatchEvent(new CustomEvent('toast.viewportResume')); + }); + + // Without the fix, onClose would never be invoked. With the fix, resume + // detects `remaining <= 0` after a real pause and closes immediately. + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx index f732b65c6..2a3922739 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -1,4 +1,11 @@ -import { createContext, useEffect, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import * as RadixUIToast from '@radix-ui/react-toast'; import { keyframes, styled } from 'styled-components'; import { toastsEventEmitter } from './toastEmitter'; @@ -7,10 +14,21 @@ import { IconButton } from '@/components/IconButton'; import { Button } from '@/components/Button'; import { ToastContextProps, ToastProps, ToastAlignment, ToastType } from './Toast.types'; +// Radix's default auto-close duration, mirrored here so our internally managed +// timer matches Radix's behaviour when no `duration` is provided. +const DEFAULT_TOAST_DURATION = 5000; + export const ToastContext = createContext({ createToast: () => null, }); +// Provider-level default duration. Mirrors Radix's `ToastProvider duration` +// prop semantics so that consumers can do `` +// (or ``) to set the +// fallback for every toast that doesn't explicitly set its own `duration`. +// Defaults to `DEFAULT_TOAST_DURATION`. +const ToastDurationContext = createContext(DEFAULT_TOAST_DURATION); + // Lazy wrapper to avoid circular dependency issues at module load time const ToastIconWrapper = (props: IconProps & { $type?: ToastType }) => ( @@ -124,11 +142,18 @@ export const Toast = ({ title, description, actions = [], - duration, + duration: durationProp, onClose, ...props }: ToastProps & { onClose: (open: boolean) => void }) => { + // Resolve the effective duration: explicit prop wins, then the provider-level + // duration from `ToastDurationContext` (`` / + // ``), then Radix's + // historical default of 5000 ms. + const providerDuration = useContext(ToastDurationContext); + const duration = durationProp ?? providerDuration; + let iconName = ''; if (type === 'default') { iconName = 'info-in-circle'; @@ -137,10 +162,101 @@ export const Toast = ({ } else if (type && ['danger', 'warning'].includes(type)) { iconName = 'warning'; } + + // We manage the auto-close timer ourselves (and pass `duration={Infinity}` to + // Radix below) so that the timer is always cleared on unmount. The Radix + // implementation (v1.2.2, still the case on 1.2.17) never clears its + // internal `closeTimerRef`, which causes the timer to fire after the host + // (e.g. jsdom in tests) has been torn down, surfacing as + // `ReferenceError: document is not defined`. See + // https://github.com/radix-ui/primitives/pull/3794. Once that upstream fix + // ships and we adopt it we can revert this in favour of Radix's own timer. + const closeTimerRef = useRef | undefined>(undefined); + const closeTimerStartTimeRef = useRef(0); + const closeTimerRemainingTimeRef = useRef(duration); + // Tracks whether `handlePause` actually paused a running timer. Without + // this, `handleResume` could not tell apart "duration was 0/unset, never + // had a timer" from "timer was running and got paused with no time left", + // and the latter case must close the toast immediately on resume. + const wasPausedRef = useRef(false); + + // Stable ref to the latest `onClose` so that `startCloseTimer` does not + // depend on `onClose`'s identity. Without this, the auto-close `useEffect` + // below would re-run (and the countdown would restart from the full + // duration) every time the parent passes a fresh function -- which + // `ToastProvider` does on every render because it uses `onClose={onClose(id)}` + // (a new closure per render). That would let a frequently-rerendering parent + // keep a toast alive indefinitely and would also discard pause progress. + const onCloseRef = useRef(onClose); + useEffect(() => { + onCloseRef.current = onClose; + }); + + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current !== undefined) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = undefined; + } + }, []); + + const startCloseTimer = useCallback( + (remaining: number) => { + clearCloseTimer(); + if (!remaining || remaining === Infinity) { + return; + } + closeTimerStartTimeRef.current = Date.now(); + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = undefined; + onCloseRef.current(false); + }, remaining); + }, + [clearCloseTimer] + ); + + useEffect(() => { + closeTimerRemainingTimeRef.current = duration; + wasPausedRef.current = false; + startCloseTimer(duration); + return clearCloseTimer; + }, [duration, startCloseTimer, clearCloseTimer]); + + const handlePause = useCallback(() => { + if (closeTimerRef.current === undefined) { + return; + } + const elapsed = Date.now() - closeTimerStartTimeRef.current; + closeTimerRemainingTimeRef.current = Math.max( + 0, + closeTimerRemainingTimeRef.current - elapsed + ); + clearCloseTimer(); + wasPausedRef.current = true; + }, [clearCloseTimer]); + + const handleResume = useCallback(() => { + if (!wasPausedRef.current) { + return; + } + wasPausedRef.current = false; + // If the deadline elapsed during the pause window (e.g. event-loop delay + // or timer throttling pushed the pause past the auto-close deadline + // before the timer callback ran), close immediately on resume instead of + // letting `startCloseTimer(0)` short-circuit and leave the toast open + // forever. + if (closeTimerRemainingTimeRef.current <= 0) { + onCloseRef.current(false); + return; + } + startCloseTimer(closeTimerRemainingTimeRef.current); + }, [startCloseTimer]); + return ( @@ -216,6 +332,7 @@ export interface ToastProviderProps extends RadixUIToast.ToastProviderProps { export const ToastProvider = ({ children, align = 'end', + duration: providerDuration = DEFAULT_TOAST_DURATION, ...props }: ToastProviderProps) => { const [toasts, setToasts] = useState>>({ @@ -268,18 +385,21 @@ export const ToastProvider = ({ - - {children} - {Array.from(toasts[align]).map(([id, toast]) => ( - - ))} - + + + {children} + {Array.from(toasts[align]).map(([id, toast]) => ( + + ))} + + );