From 669896810337453288c43104dc56f9ed686064ba Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 16:44:46 +0200 Subject: [PATCH 1/2] test(frontend): show that a toast behind a bottom sheet times out unseen On a phone on its side, a bottom sheet hides each toast but the newest, and the hidden toasts keep their timers. The three functional scenarios stop the clock before the page opens, move it by hand, and record which toasts show as time passes. So the real time that a slow browser takes does not count. All three fail. The success toast never shows, and the error toast closes while it waits, also after the focus leaves the toasts. A component test adds a toast while the pointer is on the toasts, and then a newer toast hides it. The hidden toast must not close at its first deadline. --- frontend/src/components/toaster.test.tsx | 42 ++++++++++++++ .../tests/features/toasts/toasts.locators.ts | 2 + frontend/tests/features/toasts/toasts.spec.ts | 58 +++++++++++++++++++ .../tests/features/toasts/toasts.tasks.ts | 49 ++++++++++++++++ 4 files changed, 151 insertions(+) diff --git a/frontend/src/components/toaster.test.tsx b/frontend/src/components/toaster.test.tsx index e32107ee..dba63f15 100644 --- a/frontend/src/components/toaster.test.tsx +++ b/frontend/src/components/toaster.test.tsx @@ -21,6 +21,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() toastQueue.clear() }) @@ -32,6 +33,18 @@ async function animationsFinished(): Promise { await Promise.all(document.getAnimations().map((animation) => animation.finished)) } +// A toast pauses or restarts its timer in a ResizeObserver callback. The browser runs all the callbacks of one rendering +// step in one task, so the test goes on after all of them. +async function resizeObserverCallbacks(): Promise { + await new Promise((resolve) => { + const observer = new ResizeObserver(() => { + observer.disconnect() + resolve() + }) + observer.observe(document.body) + }) +} + function boxOf(text: string): DOMRect { return page.getByRole('alertdialog').filter({ hasText: text }).element().getBoundingClientRect() } @@ -132,3 +145,32 @@ test.each(Object.entries(bottomPanels))( expect(boxOf('Could not save the changes').bottom).toBe(window.innerHeight - 16) }, ) + +test('on a phone on its side, a toast that came while the pointer was on the toasts keeps waiting when a newer toast hides it', async () => { + await page.viewport(667, 375) + await render( + <> + + + , + ) + await expect.element(page.getByRole('dialog', { name: 'Edit filter' })).toBeVisible() + // Only the test moves the clock of the toast timers, so a slow browser does not use up their time. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + Notification.info('Upload started', { duration: 20_000 }) + await expect.element(page.getByText('Upload started')).toBeVisible() + await animationsFinished() + + await userEvent.hover(page.getByText('Upload started')) + Notification.error('Could not save the changes', { duration: 10_000 }) + await expect.element(page.getByText('Could not save the changes')).toBeVisible() + await userEvent.hover(page.getByRole('heading', { name: 'Edit filter' })) + Notification.info('Upload finished', { duration: 20_000 }) + await expect.element(page.getByText('Upload finished')).toBeVisible() + await animationsFinished() + await resizeObserverCallbacks() + vi.advanceTimersByTime(10_500) + await animationsFinished() + + expect(page.getByRole('alertdialog').filter({ hasText: 'Could not save the changes' }).query()).not.toBeNull() +}) diff --git a/frontend/tests/features/toasts/toasts.locators.ts b/frontend/tests/features/toasts/toasts.locators.ts index 32c880d3..24da04f6 100644 --- a/frontend/tests/features/toasts/toasts.locators.ts +++ b/frontend/tests/features/toasts/toasts.locators.ts @@ -7,6 +7,8 @@ export const toastDemo = (page: Page) => ({ showToast: (name: ToastButton) => page.getByRole('button', { name, exact: true }), openDialog: page.getByRole('button', { name: 'Open Dialog', exact: true }), dialog: page.getByRole('dialog', { name: 'Dialog Title' }), + dialogTitle: page.getByRole('dialog', { name: 'Dialog Title' }).getByRole('heading', { name: 'Dialog Title' }), + closeDialog: page.getByRole('dialog', { name: 'Dialog Title' }).getByRole('button', { name: 'Continue' }), region: page.getByRole('region', { name: /notification/ }), toasts: page.getByRole('alertdialog'), toast: (message: string) => page.getByRole('alertdialog').filter({ hasText: message }), diff --git a/frontend/tests/features/toasts/toasts.spec.ts b/frontend/tests/features/toasts/toasts.spec.ts index 288ec0a2..3b4a9823 100644 --- a/frontend/tests/features/toasts/toasts.spec.ts +++ b/frontend/tests/features/toasts/toasts.spec.ts @@ -41,3 +41,61 @@ test.describe('the toasts on a phone on its side while a bottom sheet is open', expect(await demo.shownToasts()).toEqual(['A new version is available', 'Something went wrong']) }) }) + +// The success toast closes after 5 s, and the error toast after 6 s. +test.describe('the toasts that a bottom sheet hides on a phone on its side', () => { + test.use({ viewport: { width: 667, height: 375 } }) + + test('show one after the other, each for its full time', async ({ page }) => { + const demo = toastDemoPage(page) + await demo.controlTheClock() + await demo.open() + await demo.showToastsByKeyboard('Success', 'Error', 'Info') + + await demo.openBottomSheet() + const shown = await demo.shownToastsAsTimePasses(5_000, 5_000, 1_500) + + expect(shown).toEqual([ + { afterMs: 0, toasts: ['A new version is available'] }, + { afterMs: 5_000, toasts: ['Something went wrong'] }, + { afterMs: 10_000, toasts: ['Something went wrong'] }, + { afterMs: 11_500, toasts: ['Changes saved'] }, + ]) + }) + + test('keep waiting after the focus leaves the toasts', async ({ page }) => { + const demo = toastDemoPage(page) + await demo.controlTheClock() + await demo.open() + await demo.showToastsByKeyboard('Success', 'Error', 'Info') + + await demo.openBottomSheet() + await demo.moveFocusToToasts() + await demo.pressTab(3) + await demo.moveFocusToBottomSheet() + const shown = await demo.shownToastsAsTimePasses(5_000, 6_500) + + expect(shown).toEqual([ + { afterMs: 0, toasts: ['A new version is available'] }, + { afterMs: 5_000, toasts: ['Something went wrong'] }, + { afterMs: 11_500, toasts: ['Changes saved'] }, + ]) + }) + + test('all show again when the sheet closes, so an error that waited still shows', async ({ page }) => { + const demo = toastDemoPage(page) + await demo.controlTheClock() + await demo.open() + await demo.showToastsByKeyboard('Error', 'Success') + + await demo.openBottomSheet() + await demo.letTimePass(2_000) + await demo.closeBottomSheet() + const shown = await demo.shownToastsAsTimePasses(5_000) + + expect(shown).toEqual([ + { afterMs: 0, toasts: ['Changes saved', 'Something went wrong'] }, + { afterMs: 5_000, toasts: ['Something went wrong'] }, + ]) + }) +}) diff --git a/frontend/tests/features/toasts/toasts.tasks.ts b/frontend/tests/features/toasts/toasts.tasks.ts index 35033d1b..0dedb8d3 100644 --- a/frontend/tests/features/toasts/toasts.tasks.ts +++ b/frontend/tests/features/toasts/toasts.tasks.ts @@ -2,12 +2,35 @@ import { expect, type Locator, type Page } from '@playwright/test' import { takesUpSpace } from '@tests/support/screen' import { toastDemo, type ToastButton } from './toasts.locators' +// The toasts pause and restart their timers in ResizeObserver callbacks. The browser runs all the callbacks of one +// rendering step in one task, so the next command of the test comes after all of them. +async function nextResizeObserverCallbacks(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => { + const observer = new ResizeObserver(() => { + observer.disconnect() + resolve() + }) + observer.observe(document.body) + }), + ) +} + export function toastDemoPage(page: Page) { const at = toastDemo(page) return { at, + // The clock stands still from the start, so only letTimePass moves the timers of the toasts. Real time that a slow + // browser takes does not count. The animations wait too, so a sheet that opens stays transparent until time passes. + async controlTheClock() { + const start = new Date('2026-01-05T09:00:00Z') + await page.clock.install({ time: start }) + await page.clock.pauseAt(start.getTime() + 60_000) + }, + async open() { await page.goto('/components') await expect(at.heading).toBeVisible() @@ -21,6 +44,23 @@ export function toastDemoPage(page: Page) { async openBottomSheet() { await at.openDialog.press('Enter') await expect(at.dialog).toBeVisible() + await nextResizeObserverCallbacks(page) + }, + + async closeBottomSheet() { + await at.closeDialog.press('Enter') + await expect(at.dialog).toBeHidden() + await nextResizeObserverCallbacks(page) + }, + + async moveFocusToBottomSheet() { + await at.dialogTitle.click() + await expect(at.dialog).toBeFocused() + }, + + async letTimePass(ms: number) { + await page.clock.runFor(ms) + await nextResizeObserverCallbacks(page) }, async moveFocusToToasts() { @@ -40,6 +80,15 @@ export function toastDemoPage(page: Page) { return shown }, + async shownToastsAsTimePasses(...stepsMs: number[]) { + const samples = [{ afterMs: 0, toasts: await this.shownToasts() }] + for (const ms of stepsMs) { + await this.letTimePass(ms) + samples.push({ afterMs: samples[samples.length - 1].afterMs + ms, toasts: await this.shownToasts() }) + } + return samples + }, + async hasFocusRing(element: Locator) { return element.evaluate((node) => { const { outlineStyle, outlineWidth, boxShadow } = getComputedStyle(node) From 4525c4c93f703dc8d4b22b804aae449dbd5cdcca Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 16:44:46 +0200 Subject: [PATCH 2/2] fix(frontend): pause the timer of a toast that a bottom sheet hides While a bottom sheet is open on a phone, index.css shows only the newest toast and hides the others visually. React Aria started the timer of each toast when it mounted, so a hidden toast closed before the user saw it. An error toast behind a newer success toast was lost. The toast queue now pauses the timer of a hidden toast. When the toast shows again, it gets its full time. A ResizeObserver on each toast tells the queue when the toast is hidden, so index.css stays the one place that decides which toasts show. A pointer or focus that leaves the toast region resumes only the toasts that show. React Aria starts a second timeout when it resumes or resets a timer that runs, and a pause stops only the newest one. So the queue pauses a timer before it starts it again, and it does not resume a toast that has not rendered yet, because the toast starts its timer when it mounts. --- frontend/src/components/notification.ts | 43 +++++++++++++++++++++++-- frontend/src/components/toaster.tsx | 20 +++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/notification.ts b/frontend/src/components/notification.ts index 82002f76..2c6173ed 100644 --- a/frontend/src/components/notification.ts +++ b/frontend/src/components/notification.ts @@ -1,9 +1,9 @@ import { flushSync } from 'react-dom' -import { UNSTABLE_ToastQueue as ToastQueue } from 'react-aria-components/Toast' +import { UNSTABLE_ToastQueue as ToastQueue, type QueuedToast } from 'react-aria-components/Toast' export type ToastType = 'success' | 'error' | 'info' | 'warning' -interface ToastMessage { +export interface ToastMessage { message: string type: ToastType description?: string @@ -15,12 +15,49 @@ interface NotificationOptions { duration?: number } +// While a bottom sheet is open on a phone, index.css hides each toast but the newest. A hidden toast must not time out. +class ShownToastQueue extends ToastQueue { + // A toast has no entry until it renders. React Aria starts its timer when it mounts. + readonly #visibility = new WeakMap, 'shown' | 'hidden'>() + #isPaused = false + + markHidden(toast: QueuedToast): void { + this.#visibility.set(toast, 'hidden') + toast.timer?.pause() + } + + markShown(toast: QueuedToast): void { + const wasHidden = this.#visibility.get(toast) === 'hidden' + this.#visibility.set(toast, 'shown') + if (!wasHidden || !toast.timer || !toast.timeout) return + // React Aria starts the timer again when a toast mounts again, and reset does not clear a running timer. + toast.timer.pause() + toast.timer.reset(toast.timeout) + if (this.#isPaused) toast.timer.pause() + } + + override pauseAll(): void { + this.#isPaused = true + super.pauseAll() + } + + override resumeAll(): void { + this.#isPaused = false + for (const toast of this.visibleToasts) { + if (this.#visibility.get(toast) !== 'shown' || !toast.timer) continue + // A toast that mounts while the toasts pause starts its timer anyway, and resume does not clear a running timer. + toast.timer.pause() + toast.timer.resume() + } + } +} + /** * The queue of toasts that `Toaster` shows. Each update runs in a view * transition, so the toasts animate in and out. With reduced motion, the * toasts show and close without an animation. */ -export const toastQueue = new ToastQueue({ +export const toastQueue = new ShownToastQueue({ maxVisibleToasts: 5, wrapUpdate(fn) { if ( diff --git a/frontend/src/components/toaster.tsx b/frontend/src/components/toaster.tsx index 9da6f566..ed9d8fd9 100644 --- a/frontend/src/components/toaster.tsx +++ b/frontend/src/components/toaster.tsx @@ -6,13 +6,14 @@ import { UNSTABLE_Toast as Toast, UNSTABLE_ToastContent as ToastContent, UNSTABLE_ToastRegion as ToastRegion, + type QueuedToast, } from 'react-aria-components/Toast' import { CheckmarkCircle02Icon, AlertCircleIcon, InformationCircleIcon, Alert02Icon, Cancel01Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' -import { toastQueue } from '@/components/notification' +import { toastQueue, type ToastMessage } from '@/components/notification' // Icons for each toast type const icons = { @@ -30,6 +31,22 @@ const iconColors = { warning: 'text-chart-3', // orange from chart colors } as const +// The size of a toast that index.css hides visually (sr-only). +const hiddenToastSizePx = 1 + +function pauseWhileHidden(toast: QueuedToast) { + return (element: HTMLElement | null) => { + if (!element) return + const observer = new ResizeObserver(([entry]) => { + const [{ inlineSize, blockSize }] = entry.borderBoxSize + if (inlineSize > hiddenToastSizePx && blockSize > hiddenToastSizePx) toastQueue.markShown(toast) + else toastQueue.markHidden(toast) + }) + observer.observe(element) + return () => observer.disconnect() + } +} + /** * The region that shows the toast notifications, styled with the theme of the app. * main.tsx renders it once, at the root of the app. @@ -55,6 +72,7 @@ export function Toaster() { {({ toast }) => (