Skip to content

[toast] Fix timer and limit edge cases - #4933

Merged
atomiks merged 8 commits into
mui:masterfrom
atomiks:codex/fix-toast-edge-cases
May 29, 2026
Merged

atomiks merged 8 commits into
mui:masterfrom
atomiks:codex/fix-toast-edge-cases

Conversation

@atomiks

@atomiks atomiks commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

Toast now handles several limit, timer, and focus edge cases that could leave hidden or inactive toasts in the wrong state.

Changes

  • Recomputed limited toast state when the provider limit changes.
  • Shared limited-state recomputation across add, close, and provider sync paths.
  • Reset paused timer state when all active timers are cleared so future toasts can pause and resume normally.
  • Only prevented touch scrolling while a toast swipe is active.
  • Kept timers paused while the window is blurred, even if mouseleave or blur handlers run.
  • Skipped ending and limited toasts when focus enters the viewport.
  • Updated Toast docs and API output for the current limit and manager behavior.
  • Added regression coverage for Toast root, viewport, content, manager, and store behavior.

@atomiks atomiks added docs Improvements or additions to the documentation. type: bug It doesn't behave as expected. component: toast Changes related to the toast component. labels May 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026 •

Copy link
Copy Markdown

commit: 9bc852c

@code-infra-dashboard

code-infra-dashboard Bot commented May 28, 2026 •

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+442B(+0.10%) 🔺+104B(+0.07%)

Details of bundle changes

Performance

Total duration: 1,380.94 ms +167.95 ms(+13.8%) | Renders: 50 (+0) | Paint: 2,081.84 ms +252.13 ms(+13.8%)

Test Duration Renders
Slider mount (300 instances) 202.69 ms 🔺+34.78 ms(+20.7%) 3 (+0)
Scroll Area mount (300 instances) 102.12 ms 🔺+21.56 ms(+26.8%) 3 (+0)
Dialog mount (300 instances) 70.37 ms 🔺+16.11 ms(+29.7%) 1 (+0)
Mixed surface mount (app-like density) 85.70 ms 🔺+15.35 ms(+21.8%) 5 (+0)
Tooltip mount (300 contained roots) 63.31 ms 🔺+10.60 ms(+20.1%) 1 (+0)

7 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented May 28, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 9bc852c
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a195cff8393150008007091
😎 Deploy Preview https://deploy-preview-4933--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks
atomiks marked this pull request as ready for review May 28, 2026 12:21

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Critical Issues

None — the PR is mergeable as is.

Important Issues

  1. clearTimer reset bypassed when timer fires for a removed toast — packages/react/src/toast/store.ts:444-447, :411-419, :312-318
    scheduleTimer and resumeTimers inline this.timers.delete(id); callback() instead of routing through clearTimer. closeToast then early-returns at :314 if the toast was concurrently removed via removeToast — so the areTimersPaused = false reset never happens, re-introducing the bug this PR fixes. Narrow path (requires external removeToast of a toast with a running timer), but easy to close: extract private onTimerFired(id) { this.timers.delete(id); if (this.timers.size === 0) this.areTimersPaused = false; } and call it from both inline callbacks.

  2. store.handleDocumentPointerDown and ToastViewport.handleKeyDown (Shift+Tab) still call resumeTimers() unconditionally — store.ts:425-437, ToastViewport.tsx:152-162
    The PR added resumeTimersIfWindowFocused to handleMouseLeave/handleBlur but didn't apply the same gate elsewhere. Likely intentional (these paths imply explicit user activity), but worth confirming; if intentional, a one-line comment explains the asymmetry.

  3. Touchmove test doesn't verify scroll re-enables after pointer end — packages/react/src/toast/root/ToastRoot.test.tsx:584-596
    The new test fires pointerCancel but doesn't re-dispatch a touchmove afterward to assert defaultPrevented === false. Add one more dispatch after pointerCancel to lock in the contract.

  4. Brittle addEventListenerSpy reversed-find pattern — ToastViewport.test.tsx ~:492-494, :539-541
    Selecting the listener by last-registered ordering is fragile. Prefer dispatching a real window.dispatchEvent(blurEvent) or filter by the capture flag (the viewport's blur listener is the only true-capture one in these tests).

Suggestions

  1. applyLimited rot risk — store.ts:69-83
    Comment asserts "Toasts are ordered newest-first" but the function trusts iteration order. Tighten to "Caller guarantees newest-first ordering (see addToast)."

  2. Docs use "inert" colloquially — docs/.../page.mdx:151, types.md, ToastProvider.tsx:75
    Implementation does set the HTML inert attribute (ToastRoot.tsx:521), so consider naming it: "remain mounted with the inert attribute".

  3. Missing test coverage:

    • Limit change while toasts are mid-ending (current "limit" test uses only starting toasts).
    • Timer-elapsed callback path for clearTimer (covers Finding 1).
    • Focus guard with limit > 0 and all visible toasts ending (only limit=0 covered).
    • syncProviderProps early-return reference stability.
  4. Limited flag not cleared on ending toasts — store.ts:69-83
    applyLimited skips ending toasts entirely, whereas the old addToast reset limited: false on every toast. Almost certainly benign (toast is on its way out), but no test pins this transition down.

Strengths

  • applyLimited cleanly deduplicates three previously-divergent code paths and is reference-stable to avoid extra re-renders.
  • syncProviderProps correctly uses useIsoLayoutEffect per AGENTS.md with the right dep array and an early-return for unchanged values.
  • The hasActiveToasts check (replacing toasts.length === 1) genuinely fixes a corner case where ending-only toasts left hovering/focused stuck true.
  • The 5 timer pausing unit tests in store.test.ts enumerate failure modes in DAMP style — each test clearly documents the bug it pins down.
  • Public docs accurately reflect the new behavior (data-limited ↔ inert attribute is correctly wired up).
  • F6 focus-guard skip correctly falls back to restoreFocusToPrevElement when no toast can receive focus.
  • Tests follow project conventions (vitest + jest-dom only, it.skipIf used correctly, no chai/sinon).

Recommended Action

  1. (Optional but cheap) Centralize the areTimersPaused reset into a single helper called from both scheduleTimer/resumeTimers inline callbacks (Finding 1).
  2. Add the missing post-cancel touchmove assertion (Finding 3).
  3. Decide intent on handleDocumentPointerDown/handleKeyDown resume paths (Finding 2) and either gate them or add a comment.
  4. Consider the docs/comment polish (Findings 5, 6).
  5. Test gaps (Finding 7) are nice-to-haves — current coverage is already strong for the fixes shipped.

@atomiks
atomiks force-pushed the codex/fix-toast-edge-cases branch from 7295cac to 067b981 Compare May 29, 2026 08:37
@atomiks
atomiks merged commit b35d322 into mui:master May 29, 2026
23 checks passed
@atomiks
atomiks deleted the codex/fix-toast-edge-cases branch May 29, 2026 09:47
@michaldudak michaldudak mentioned this pull request Jun 17, 2026
@michaldudak michaldudak removed the docs Improvements or additions to the documentation. label Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: toast Changes related to the toast component. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants