#786·sonner

`Toaster` remount re-shows a toast that was still active when it unmounted, and keeps re-showing it indefinitely (2.0.7 → 2.0.8)

Author: takecchiCreated Sep 9, 2026Updated Sep 9, 2026

Summary

In 2.0.8, mounting a Toaster replays every still-active toast to the new subscriber. If a Toaster unmounts while a toast is on screen, that toast is re-shown on every subsequent Toaster mount, and it never stops being replayed — the toast's auto-close timer lives in the Toast component, so it never runs while nothing is mounted, and the toast is therefore never marked as dismissed.

I believe this is a side effect of the intentional fix for #723 (delivering toasts that were created before the Toaster subscribed), which landed in #777. The replay itself does what it says — this report is about the case where the replayed toast is one that has already been displayed, and where the Toaster can unmount.

What changed

src/state.ts, Observer.subscribe (v2.0.7...v2.0.8):

diff
 subscribe = (subscriber) => {
   this.subscribers.push(subscriber);
+  // A toast can be created before the `Toaster` had a chance to subscribe...
+  // Replay whatever is still active so it doesn't get lost.
+  this.getActiveToasts().forEach((toast) => subscriber(toast as ToastT));

ToastState is a module-level singleton (export const ToastState = new Observer()), so it outlives React unmounts.

Why it never stops

  • getActiveToasts() is this.toasts.filter((toast) => !this.dismissedToasts.has(toast.id)).
  • A toast enters dismissedToasts via ToastState.dismiss(id), which for an auto-closing toast is reached through ToastdeleteToast()removeToast().
  • That path is driven by setTimeout(..., remainingTime.current) inside the Toast component.
  • While no Toaster is mounted, that timer does not exist, so the toast is never auto-closed and never enters dismissedToasts.
  • Result: it stays in getActiveToasts() forever and is replayed on every future subscribe.

Reproduction

Standalone project, no framework involved. sonner is the only runtime dependency under test.

package.json

json
{
  "name": "sonner-replay-repro",
  "private": true,
  "type": "module",
  "scripts": { "test": "vitest run" }
}

Install: npm i react@19 react-dom@19 [email protected] @testing-library/react@16 vitest jsdom @vitejs/plugin-react

vitest.config.js

javascript
import react from '@vitejs/plugin-react';
export default { plugins: [react()], test: { environment: 'jsdom', globals: true } };

replay.test.jsx

javascript
import { describe, it, expect } from 'vitest';
import { render, cleanup, screen } from '@testing-library/react';
import { Toaster, toast } from 'sonner';

const visible = async () => {
  try {
    await screen.findByText('hello', undefined, { timeout: 1200 });
    return true;
  } catch {
    return false;
  }
};

describe('Toaster remount', () => {
  it('does not re-show a toast that was on screen when the Toaster unmounted', async () => {
    render(<Toaster />);
    toast.error('hello');
    expect(await screen.findByText('hello')).toBeTruthy();

    // Unmount the Toaster while the toast is still on screen.
    cleanup();

    // Mount a fresh Toaster. No toast() call is made from here on.
    render(<Toaster />);
    const mount2 = await visible();

    cleanup();
    render(<Toaster />);
    const mount3 = await visible();

    // Stay unmounted for longer than the default 4s duration, then mount again.
    cleanup();
    await new Promise((r) => setTimeout(r, 6000));
    render(<Toaster />);
    const after6s = await visible();

    expect({ mount2, mount3, after6s }).toEqual({
      mount2: false,
      mount3: false,
      after6s: false,
    });
  }, 40000);
});

Result

sonner outcome
2.0.7 passes
2.0.8 fails with { mount2: true, mount3: true, after6s: true }
2.0.7 again passes

Measured on the same project with only the sonner version changed (react 19.2.8, @testing-library/react 16, vitest 5, jsdom).

after6s: true is the part I would highlight: the default duration is 4s, so a naive expectation would be that the toast expires and stops being replayed. It does not, because nothing is mounted to run the timer.

When this shows up in an app

Any app where the Toaster is not permanently mounted — for example rendered inside a subtree behind a conditional, inside an error-boundary fallback path, or inside a layout that can be swapped out. A toast that was on screen at that moment comes back on the next mount, without any new toast() call, and keeps coming back.

Possible directions (not a preference — the design call is yours)

A few options that would each address it, listed only to make the trade-offs concrete:

  • Make the replay opt-in on <Toaster /> (e.g. a prop), so existing apps keep the 2.0.7 behaviour by default.
  • Only replay toasts that have not been rendered yet — i.e. distinguish "queued before any subscriber existed" (the #723 case) from "already displayed, then the subscriber went away".
  • Treat a Toaster unmount as the end of the toast's life for toasts it was showing (mark them dismissed on unmount), so the singleton does not accumulate them.

Happy to test a patch against the repro above if that helps.