Skip to content

fix: deliver toasts queued before Toaster subscribes (#723) - #760

Open
rolkec wants to merge 3 commits into
emilkowalski:mainfrom
rolkec:fix/queue-toasts-before-subscribe
Open

fix: deliver toasts queued before Toaster subscribes (#723)#760
rolkec wants to merge 3 commits into
emilkowalski:mainfrom
rolkec:fix/queue-toasts-before-subscribe

Conversation

@rolkec

@rolkec rolkec commented May 15, 2026

Copy link
Copy Markdown

Closes #723.

Problem

toast(...) called before <Toaster /> mounts (e.g. from a sibling
component's useEffect, which runs before the parent's) was silently
dropped. Observer.addToast publishes to subscribers, but
Toaster.subscribe only attaches inside its own useEffect, so any
toast added before that ran was never delivered. The user could see it
in ToastState.toasts but it never reached the DOM.

The only workarounds were setTimeout(0) around the toast() call or
re-ordering the component tree so <Toaster /> mounts first — both
documented in the issue, neither pleasant.

Fix

Two small, related changes:

  1. src/state.ts — replay queued toasts on subscribe. When a new
    subscriber attaches, deliver any non-dismissed toasts already in
    this.toasts. Also guards the unsubscribe splice against
    indexOf returning -1 (previously silently removed the last
    subscriber).

  2. src/index.tsx — drop the stale [toasts] dep on the Toaster's
    subscribe effect.
    The setter is a functional update and doesn't
    need the value in deps. Without this, change 1 caused an infinite
    subscribe/replay/setState loop because every state change
    re-triggered the effect and re-replayed the queue. The per-toast
    auto-dismiss timer (which has toast in its deps) was cancelled and
    restarted on each iteration, so toasts appeared but never dismissed.

    Independently of change 1, the old [toasts] dep also created a
    race where any toast published between an unsubscribe and the
    matching re-subscribe could be lost.

Test plan

  • Added /issue-723 route in test/src/app/ that reproduces the
    original race: a sibling component fires toast() from its
    useEffect while rendered above <Toaster />.
  • Added a Playwright regression test in test/tests/basic.spec.ts.
  • Verified manually in production build (pnpm --filter test build && pnpm --filter test start): toast appears and auto-dismisses after
    the default 4 s.
  • Existing test suite still passes locally.

rolkef added 3 commits May 15, 2026 14:17
… mounts are not lost

Closes emilkowalski#723

When `toast()` is called from a component that renders/mounts before
`<Toaster />` (e.g. a sibling's `useEffect` running first), the toast is
added to `ToastState.toasts` but `publish()` has no subscribers yet, so
the toast is never displayed. The only workarounds were `setTimeout(0)`
or re-ordering components.

Fix: on `subscribe()`, replay any non-dismissed toasts already in the
queue to the newly attached subscriber. Also guards the `unsubscribe`
splice against `indexOf` returning -1 (which previously would silently
remove the last subscriber).

Adds a regression test at /issue-723 that reproduces the original race.
…s don't churn

Companion to the previous commit. The Toaster's subscribe useEffect used
`[toasts]` as its dependency array, but the setter inside is a functional
update (`setToasts((toasts) => ...)`) and does not close over the value,
so the dep was unnecessary.

With the new replay-on-subscribe behavior from the previous commit, this
stale dep caused an infinite loop: every state change triggered a
re-subscribe, which replayed the queued toast, which produced a new state
update, which triggered another re-subscribe, and so on. The per-toast
auto-dismiss timer in Toast.tsx has `toast` in its deps, so it was
cancelled and restarted on every iteration and never fired -- visible
symptom: toast appears but never auto-dismisses.

Independently of the replay fix, the old `[toasts]` dep also created a
race where toasts published between an unsubscribe and the matching
re-subscribe could be lost.
…nvoke

Next dev mode runs `useEffect` twice on mount; without a guard the
fixture fired two toasts and the regression assertion `toHaveCount(1)`
became environment-dependent. A `useRef` "already fired" check makes
the page render exactly one toast under both dev and prod builds, which
matches how applications usually structure on-mount side effects.
@vercel

vercel Bot commented May 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sonner Error Error May 15, 2026 4:19pm

Request Review

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.

Toast is never shown if called before Toaster subscribes to ToastState

2 participants