Skip to content

fix(Toast): clear auto-close timer on unmount (jsdom teardown crash)#1091

Open
Firefds wants to merge 3 commits into
mainfrom
shauli/fix-toast-timer-cleanup-on-unmount-b3a9
Open

fix(Toast): clear auto-close timer on unmount (jsdom teardown crash)#1091
Firefds wants to merge 3 commits into
mainfrom
shauli/fix-toast-timer-cleanup-on-unmount-b3a9

Conversation

@Firefds

@Firefds Firefds commented Jun 22, 2026

Copy link
Copy Markdown
Member

Why?

Radix @radix-ui/react-toast@1.2.2 (and the latest published 1.2.17, verified by diffing ToastImpl) never clears its internal closeTimerRef when it unmounts. The leftover setTimeout fires after the host has gone away — in jsdom-based test runs, vitest tears down the file's DOM before the timer fires, then Radix's handleClose does node?.contains(document.activeElement) and crashes with ReferenceError: document is not defined, aborting the entire test suite for that file.

Upstream fix is open but unmerged: radix-ui/primitives#3794 (tracking issue: radix-ui/primitives#3703). When that ships and we bump our @radix-ui/react-toast dep, this wrapper-side workaround can be reverted.

How?

Take ownership of the auto-close timer inside click-ui's Toast wrapper instead of relying on Radix's internal one:

  • Always pass duration={Infinity} to the underlying RadixUIToast.Root. Radix's startTimer short-circuits on Infinity (if (!duration2 || duration2 === Infinity) return;), so no real setTimeout is ever scheduled inside Radix — no leak possible.
  • Manage closeTimerRef ourselves with useRef + setTimeout, and clear it from the useEffect cleanup, so the timer is guaranteed to be cancelled on unmount.
  • Wire onPause / onResume (public Radix props) to pause/resume our own timer. This preserves the existing viewport-level pause-on-blur / pause-on-hover behaviour for production consumers.
  • Resolve the effective duration as toast.duration ?? providerDuration ?? DEFAULT_TOAST_DURATION via a new internal ToastDurationContext populated by ToastProvider. This preserves the previous prop > provider > default precedence so <ToastProvider duration={...}> / <ClickUIProvider config={{ toast: { duration } }}> still works.
  • Keep onClose behind a stable callback ref (the same useCallbackRef pattern Radix uses internally), so the auto-close useEffect does not re-run when the parent passes a new onClose identity. This matters because ToastProvider produces a new onClose={onClose(id)} closure on every render — without the ref, every provider update (e.g. another toast appearing) would restart the countdown from the full duration and discard pause progress, letting a frequently-rerendering parent keep a toast alive indefinitely.
  • Track wasPausedRef so that handleResume can distinguish "never paused" from "paused with deadline already elapsed". In the latter case (event-loop delay / timer throttling beat the close timer to the punch), handleResume now closes immediately instead of calling startCloseTimer(0) and short-circuiting forever.

Added src/components/Toast/Toast.test.tsx with six cases, four of them targeted regression tests:

  1. does not leak the auto-close timer past unmount (regression for radix-ui/primitives#3703) — stubs Document.prototype.activeElement and advances time past the leaked deadline. If Radix's auto-close handler ever fires post-unmount it dereferences document.activeElement, tripping the stub. Asserts the stub was never tripped, which is insensitive to unrelated Radix / styled-components internal timer counts.
  2. does not reset the auto-close timer when the parent re-renders with a new onClose identity — re-renders with a fresh onClose mid-countdown and asserts the original deadline still fires.
  3. falls back to the provider-level duration when a toast does not set its own — creates a toast via useToast().createToast under a <ClickUIProvider config={{ toast: { duration: 2000 } }}> and asserts the toast disappears just past 2000 ms (not the 5000 ms hard default).
  4. closes immediately on resume when the auto-close deadline elapsed during a pause — advances the wall clock past duration without draining the fake-timer queue, then dispatches Radix's toast.viewportPause / toast.viewportResume events on the viewport <ol>. Asserts onClose(false) fires immediately on resume.

Verified: each new test fails on the corresponding pre-fix code path (parent-re-render, provider-duration, resume-after-deadline) and passes with the fix applied.

Verification

  • yarn test480 passed (59 files), including the new 6-test Toast suite.
  • yarn typecheck — clean.
  • yarn lint — clean (no new warnings/errors; same 13 pre-existing warnings in unrelated files).
  • yarn build — clean; Toast chunk built successfully.
  • Counter-tests: with the fix file stashed, the parent-rerender, provider-duration, and resume-after-deadline tests fail individually, confirming each assertion exercises its target bug.
  • Confirmed upgrading the dep would not fix this natively: latest published @radix-ui/react-toast@1.2.17 (2026-06-15) still has the same closeTimerRef-without-unmount-cleanup code path as 1.2.2; only whitespace differs in ToastImpl.

Tickets?

Contribution checklist?

  • You've done enough research before writing
  • You have reviewed the PR
  • The commit messages are detailed
  • The build command runs locally
  • Assets or static content are linked and stored in the project — n/a
  • For documentation, guides or references, you've tested the commands — n/a

Security checklist?

  • All user inputs are validated and sanitized — n/a
  • No usage of dangerouslySetInnerHTML
  • Sensitive data has been identified and is being protected properly — n/a
  • Build output contains no secrets or API keys

Preview?

n/a — internal timer / lifecycle change with no visible UI/UX delta.

Slack Thread

Open in Web Open in Cursor 

Radix Toast v1.2.2 never clears its internal `closeTimerRef` on unmount,
which leaks the auto-close `setTimeout` past the host's lifetime. In
jsdom-based test runs the leftover timer fires after vitest tears down
the test file's DOM, and `handleClose` -> `document.activeElement`
throws `ReferenceError: document is not defined`, aborting the whole
suite. (Upstream fix: radix-ui/primitives#3794, still unmerged.)

Side-step it inside click-ui itself: pass `duration={Infinity}` to the
underlying Radix `Toast.Root` (which short-circuits its `startTimer`
so no real timeout is scheduled) and own the auto-close timer in our
wrapper with a `useEffect` cleanup. Radix's public `onPause`/`onResume`
callbacks are wired through so viewport-level pause-on-blur/focus
behavior is preserved.

Co-authored-by: Shauli Bracha <shauli.bracha@clickhouse.com>
@changeset-bot

changeset-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3022a2c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@Firefds Firefds requested review from DreaminDani, Copilot, serdec and vineethasok and removed request for serdec and vineethasok June 22, 2026 13:33
@Firefds Firefds marked this pull request as ready for review June 22, 2026 13:34
Comment thread src/components/Toast/Toast.tsx

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a downstream workaround in Click UI’s Toast wrapper to prevent a Radix Toast auto-close timer from leaking past unmount, which can crash jsdom-based test teardown. It does so by disabling Radix’s internal duration timer and managing the auto-close lifecycle within Click UI, plus adds a small test suite to guard against regressions.

Changes:

  • Implement internal auto-close timer management in Toast and clear it on unmount; wire Radix onPause/onResume to pause/resume the internal timer.
  • Force duration={Infinity} on the underlying Radix toast to avoid Radix’s internal timer scheduling.
  • Add Toast.test.tsx with auto-close, unmount cleanup regression, and Infinity duration behavior tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/components/Toast/Toast.tsx Moves auto-close timing responsibility into Click UI and disables Radix’s internal duration timer.
src/components/Toast/Toast.test.tsx Adds unit tests validating auto-close timing and ensuring no timers remain after unmount.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 125 to 132
export const Toast = ({
type,
toastType = 'foreground',
title,
description,
actions = [],
duration,
duration = DEFAULT_TOAST_DURATION,
onClose,
Comment on lines +196 to +198
const handleResume = useCallback(() => {
startCloseTimer(closeTimerRemainingTimeRef.current);
}, [startCloseTimer]);
Comment thread src/components/Toast/Toast.test.tsx Outdated
Comment on lines +35 to +67
it('clears the auto-close timer when the toast unmounts (regression for radix-ui/primitives#3703)', () => {
const onClose = vi.fn();
const { unmount } = renderCUI(
<Toast
title="hello"
duration={5000}
onClose={onClose}
/>
);

act(() => {
vi.advanceTimersByTime(1000);
});
expect(onClose).not.toHaveBeenCalled();

unmount();

// No pending timers must remain after unmount. Radix v1.2.2 leaks its
// internal `closeTimerRef` (the auto-close `setTimeout`) here because it
// never `clearTimeout`s it on unmount. The leftover timer later fires
// after vitest tears down jsdom and calls `handleClose`, which accesses
// `document.activeElement` -- producing
// `ReferenceError: document is not defined` and aborting the whole test
// file. We side-step this by passing `duration={Infinity}` to Radix and
// owning the timer in click-ui itself with a cleanup effect.
expect(vi.getTimerCount()).toBe(0);

// And nothing left behind should be able to invoke our `onClose` later.
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(onClose).not.toHaveBeenCalled();
});
The previous commit put `onClose` in the dep array of `startCloseTimer`
(and therefore of the auto-close `useEffect`). `ToastProvider` produces
a new `onClose={onClose(id)}` closure on every render, so any provider
update (e.g. another toast appearing) would re-run the effect, reset
`closeTimerRemainingTimeRef` to the full duration, and discard pause
progress -- effectively letting a frequently-rerendering parent keep a
toast alive indefinitely.

Mirror Radix's own `useCallbackRef` pattern: keep a ref to the latest
`onClose` (updated in an effect), have `startCloseTimer` invoke
`onCloseRef.current` instead of capturing `onClose` directly, and drop
`onClose` from its deps. The effect now only re-runs when `duration`
actually changes.

Added a regression test that re-renders the toast with a fresh `onClose`
identity mid-countdown and asserts the original timer still fires at the
original deadline (test fails before this commit, passes after).

Co-authored-by: Shauli Bracha <shauli.bracha@clickhouse.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 177d109. Configure here.

duration={duration}
duration={Infinity}
onPause={handlePause}
onResume={handleResume}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Props override pause handlers

Low Severity

ToastRoot sets onPause and onResume for the internal auto-close timer, then spreads {...props} afterward. ToastProps inherits Radix onPause/onResume, so values from createToast or {...toast} replace those handlers. The click-ui timer keeps running on hover, blur, and viewport pause, breaking documented Radix pause behavior for those toasts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 177d109. Configure here.

…eadline, baseline-relative leak test)

Addresses three issues raised in PR review:

1) Provider-level duration (Copilot, Toast.tsx:132)
   Previously, an unset `ToastProps.duration` let Radix's
   `ToastProvider duration` (the default for every toast) take effect.
   After moving the timer into our wrapper, defaulting `duration` to
   `5000` at the `Toast` component boundary silently overrode any
   consumer-supplied `<ToastProvider duration={...}>` / `config.toast`
   value. Add an internal `ToastDurationContext` populated by
   `ToastProvider` (still defaulting to 5000), and resolve in `Toast`
   as `toast.duration ?? providerDuration ?? DEFAULT_TOAST_DURATION` --
   restoring the original prop > provider > default resolution order.

2) Resume-after-deadline close (Copilot, Toast.tsx:211)
   If `handlePause` ran after the auto-close deadline had already
   passed (event-loop delay / timer throttling beat the close timer to
   the punch), remaining time would clamp to 0 and `handleResume ->
   startCloseTimer(0)` short-circuited, leaving the toast open
   indefinitely. Track `wasPausedRef` so resume can distinguish
   "never paused" from "paused with deadline already elapsed" and
   call `onClose(false)` immediately in the latter case.

3) Baseline-relative leak detection (Copilot, Toast.test.tsx:67)
   Replaced the brittle `getTimerCount() === 0` assertion with a
   semantic check: stub `Document.prototype.activeElement` and
   advance time past the leaked deadline. If Radix's auto-close
   handler ever fires post-unmount it dereferences
   `document.activeElement`, which now flips a boolean we assert
   against. Insensitive to unrelated Radix/styled-components internal
   timer counts.

Added two new regression tests covering #1 and #2; both fail on `main`
without the corresponding fix and pass with it.

Co-authored-by: Shauli Bracha <shauli.bracha@clickhouse.com>
@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Storybook Preview Deployed

✅ Preview URL: https://click-84yry3toy-clickhouse.vercel.app

Built from commit: cc9f565e755b33aab17149401660aafd19d3baca

@DreaminDani DreaminDani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like a lot of tests for one small change but otherwise LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants