From e1219a166ac2422bcc26a1274931a7d924cc1123 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:02:09 +0200 Subject: [PATCH] refactor(toast): share the toast queue between desktop and phone Both presenters ran their own queue behind the window toast bus. The queue, the auto dismiss and the exit delay now live in useToastQueue; each presenter keeps its markup, its exit timing and its unmount handover. --- client/src/components/shared/Toast.tsx | 55 +----------- .../components/shared/useToastQueue.test.ts | 86 +++++++++++++++++++ client/src/components/shared/useToastQueue.ts | 73 ++++++++++++++++ client/src/mobile/components/MToastHost.tsx | 67 +++------------ 4 files changed, 172 insertions(+), 109 deletions(-) create mode 100644 client/src/components/shared/useToastQueue.test.ts create mode 100644 client/src/components/shared/useToastQueue.ts diff --git a/client/src/components/shared/Toast.tsx b/client/src/components/shared/Toast.tsx index 9279a1e69..872b3f9b3 100644 --- a/client/src/components/shared/Toast.tsx +++ b/client/src/components/shared/Toast.tsx @@ -1,15 +1,6 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react' +import React, { useCallback } from 'react' import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react' - -type ToastType = 'success' | 'error' | 'warning' | 'info' - -interface Toast { - id: number - message: string - type: ToastType - duration: number - removing: boolean -} +import { useToastQueue, type ToastType } from './useToastQueue' declare global { interface Window { @@ -17,8 +8,6 @@ declare global { } } -let toastIdCounter = 0 - const ICON_COLORS: Record = { success: '#22c55e', error: '#ef4444', @@ -27,45 +16,7 @@ const ICON_COLORS: Record = { } export function ToastContainer() { - const [toasts, setToasts] = useState([]) - const timersRef = useRef[]>([]) - - useEffect(() => { - return () => { - timersRef.current.forEach(clearTimeout) - } - }, []) - - const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => { - const id = ++toastIdCounter - setToasts(prev => [...prev, { id, message, type, duration, removing: false }]) - - if (duration > 0) { - const t1 = setTimeout(() => { - setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) - const t2 = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) - }, 400) - timersRef.current.push(t2) - }, duration) - timersRef.current.push(t1) - } - - return id - }, []) - - const removeToast = useCallback((id: number) => { - setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) - const t = setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== id)) - }, 400) - timersRef.current.push(t) - }, []) - - useEffect(() => { - window.__addToast = addToast - return () => { delete window.__addToast } - }, [addToast]) + const { toasts, dismissToast: removeToast } = useToastQueue({ exitMs: 400, restorePrevious: false }) const icons: Record = { success: , diff --git a/client/src/components/shared/useToastQueue.test.ts b/client/src/components/shared/useToastQueue.test.ts new file mode 100644 index 000000000..d46dc27f8 --- /dev/null +++ b/client/src/components/shared/useToastQueue.test.ts @@ -0,0 +1,86 @@ +// FE-COMP-TOASTQUEUE-001 to -006: the toast queue both toast presenters share. +import { act, renderHook } from '@testing-library/react'; + +import { useToastQueue } from './useToastQueue'; + +beforeEach(() => { + vi.useFakeTimers(); + delete window.__addToast; +}); +afterEach(() => { + vi.useRealTimers(); + delete window.__addToast; +}); + +describe('useToastQueue', () => { + it('FE-COMP-TOASTQUEUE-001: registers the bus and queues a toast with the default type and duration', () => { + const { result } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + let id = 0; + act(() => { + id = window.__addToast!('Saved'); + }); + expect(id).toBeGreaterThan(0); + expect(result.current.toasts).toEqual([{ id, message: 'Saved', type: 'info', duration: 3000, removing: false }]); + }); + + it('FE-COMP-TOASTQUEUE-002: auto-dismisses after the duration, then drops the toast after the exit time', () => { + const { result } = renderHook(() => useToastQueue({ exitMs: 220, restorePrevious: true })); + act(() => { + window.__addToast!('Done', 'success', 1000); + }); + act(() => vi.advanceTimersByTime(999)); + expect(result.current.toasts[0].removing).toBe(false); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.toasts[0].removing).toBe(true); + act(() => vi.advanceTimersByTime(219)); + expect(result.current.toasts).toHaveLength(1); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.toasts).toEqual([]); + }); + + it('FE-COMP-TOASTQUEUE-003: a duration of zero keeps the toast until it is dismissed', () => { + const { result } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + act(() => { + window.__addToast!('Sticky', 'warning', 0); + }); + act(() => vi.advanceTimersByTime(60_000)); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].removing).toBe(false); + + act(() => result.current.dismissToast(result.current.toasts[0].id)); + expect(result.current.toasts[0].removing).toBe(true); + act(() => vi.advanceTimersByTime(400)); + expect(result.current.toasts).toEqual([]); + }); + + it('FE-COMP-TOASTQUEUE-004: the desktop mode deletes the bus on unmount', () => { + const previous = vi.fn(() => 1); + window.__addToast = previous; + const { unmount } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + expect(window.__addToast).not.toBe(previous); + unmount(); + expect(window.__addToast).toBeUndefined(); + }); + + it('FE-COMP-TOASTQUEUE-005: the phone mode hands the bus back to the previous holder on unmount', () => { + const previous = vi.fn(() => 1); + window.__addToast = previous; + const { unmount } = renderHook(() => useToastQueue({ exitMs: 220, restorePrevious: true })); + expect(window.__addToast).not.toBe(previous); + unmount(); + expect(window.__addToast).toBe(previous); + }); + + it('FE-COMP-TOASTQUEUE-006: pending timers are cleared on unmount', () => { + const clear = vi.spyOn(globalThis, 'clearTimeout'); + const { unmount } = renderHook(() => useToastQueue({ exitMs: 400, restorePrevious: false })); + act(() => { + window.__addToast!('One', 'info', 1000); + window.__addToast!('Two', 'error', 2000); + }); + const before = clear.mock.calls.length; + unmount(); + expect(clear.mock.calls.length - before).toBe(2); + clear.mockRestore(); + }); +}); diff --git a/client/src/components/shared/useToastQueue.ts b/client/src/components/shared/useToastQueue.ts new file mode 100644 index 000000000..269d8d7d0 --- /dev/null +++ b/client/src/components/shared/useToastQueue.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface QueuedToast { + id: number; + message: string; + type: ToastType; + /** duration <= 0: no auto-dismiss. */ + duration: number; + removing: boolean; +} + +interface ToastQueueOptions { + /** How long a dismissed toast keeps rendering for its exit animation. */ + exitMs: number; + /** On unmount, hand `window.__addToast` back to whoever held it before (else it is deleted). */ + restorePrevious: boolean; +} + +let toastIdCounter = 0; + +/** + * The toast queue behind the global `window.__addToast` bridge (fed by useToast and + * store/notify): registers itself while mounted, auto-dismisses after `duration`, + * keeps a dismissed toast around for `exitMs` so the view can animate it out. + */ +export function useToastQueue({ exitMs, restorePrevious }: ToastQueueOptions) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef[]>([]); + + useEffect(() => { + const timers = timersRef.current; + return () => timers.forEach(clearTimeout); + }, []); + + const dismissToast = useCallback( + (id: number) => { + setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, removing: true } : t))); + const t = setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, exitMs); + timersRef.current.push(t); + }, + [exitMs] + ); + + const addToast = useCallback( + (message: string, type: ToastType = 'info', duration: number = 3000) => { + const id = ++toastIdCounter; + setToasts((prev) => [...prev, { id, message, type, duration, removing: false }]); + + if (duration > 0) { + const t = setTimeout(() => dismissToast(id), duration); + timersRef.current.push(t); + } + + return id; + }, + [dismissToast] + ); + + useEffect(() => { + const previous = window.__addToast; + window.__addToast = addToast; + return () => { + if (restorePrevious) window.__addToast = previous; + else delete window.__addToast; + }; + }, [addToast, restorePrevious]); + + return { toasts, dismissToast }; +} diff --git a/client/src/mobile/components/MToastHost.tsx b/client/src/mobile/components/MToastHost.tsx index 0d29b7b31..429e05c53 100644 --- a/client/src/mobile/components/MToastHost.tsx +++ b/client/src/mobile/components/MToastHost.tsx @@ -1,18 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react' import MStatusDot, { MStatus } from './MStatusDot' +import { useToastQueue, type QueuedToast, type ToastType } from '../../components/shared/useToastQueue' -type ToastType = 'success' | 'error' | 'warning' | 'info' - -interface MToast { - id: number - message: string - type: ToastType - /** duration <= 0: no auto-dismiss, the pill closes on tap instead. */ - sticky: boolean - removing: boolean -} - -let toastId = 0 const EXIT_MS = 220 // Roughly what one line of the pill holds on a 375px phone. Longer messages @@ -27,18 +15,20 @@ const TYPE_DOT: Partial> = { warning: 'pending', } -function MToastPill({ toast, onDismiss }: { toast: MToast; onDismiss: (id: number) => void }) { +function MToastPill({ toast, onDismiss }: { toast: QueuedToast; onDismiss: (id: number) => void }) { const dot = TYPE_DOT[toast.type] const wraps = toast.message.length > PILL_CHARS + // duration <= 0: no auto-dismiss, the pill closes on tap instead. + const sticky = toast.duration <= 0 return (
onDismiss(toast.id) : undefined} - onKeyDown={toast.sticky ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onDismiss(toast.id) } } : undefined} + role={sticky ? 'button' : undefined} + tabIndex={sticky ? 0 : undefined} + onClick={sticky ? () => onDismiss(toast.id) : undefined} + onKeyDown={sticky ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onDismiss(toast.id) } } : undefined} className={`flex max-w-full gap-2 bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg shadow-[0_10px_30px_-8px_rgba(0,0,0,.5)] ${ wraps ? 'items-start rounded-2xl' : 'items-center rounded-full' - } ${toast.sticky ? 'pointer-events-auto cursor-pointer' : ''} ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`} + } ${sticky ? 'pointer-events-auto cursor-pointer' : ''} ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`} > {dot && } {/* No display utility next to line-clamp: Tailwind emits it later and kills the clamp. */} @@ -55,44 +45,7 @@ function MToastPill({ toast, onDismiss }: { toast: MToast; onDismiss: (id: numbe * The desktop ToastContainer handler is restored on unmount. */ export default function MToastHost() { - const [toasts, setToasts] = useState([]) - const timersRef = useRef[]>([]) - - useEffect(() => { - const timers = timersRef.current - return () => timers.forEach(clearTimeout) - }, []) - - const dismissToast = useCallback((id: number) => { - setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, removing: true } : t))) - const t = setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)) - }, EXIT_MS) - timersRef.current.push(t) - }, []) - - const addToast = useCallback( - (message: string, type: ToastType = 'info', duration: number = 3000) => { - const id = ++toastId - setToasts((prev) => [...prev, { id, message, type, sticky: duration <= 0, removing: false }]) - - if (duration > 0) { - const t = setTimeout(() => dismissToast(id), duration) - timersRef.current.push(t) - } - - return id - }, - [dismissToast], - ) - - useEffect(() => { - const previous = window.__addToast - window.__addToast = addToast - return () => { - window.__addToast = previous - } - }, [addToast]) + const { toasts, dismissToast } = useToastQueue({ exitMs: EXIT_MS, restorePrevious: true }) if (toasts.length === 0) return null