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.
This commit is contained in:
Maurice
2026-10-09 10:02:09 +02:00
parent 7261520659
commit e1219a166a
4 changed files with 172 additions and 109 deletions
+3 -52
View File
@@ -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<ToastType, string> = {
success: '#22c55e',
error: '#ef4444',
@@ -27,45 +16,7 @@ const ICON_COLORS: Record<ToastType, string> = {
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
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<ToastType, React.ReactNode> = {
success: <CheckCircle size={18} style={{ color: ICON_COLORS.success, flexShrink: 0 }} />,
@@ -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();
});
});
@@ -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<QueuedToast[]>([]);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
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 };
}
+10 -57
View File
@@ -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<Record<ToastType, MStatus>> = {
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 (
<div
role={toast.sticky ? 'button' : undefined}
tabIndex={toast.sticky ? 0 : undefined}
onClick={toast.sticky ? () => 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 && <MStatusDot status={dot} size={6} className={wraps ? 'mt-[0.45rem]' : ''} />}
{/* 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<MToast[]>([])
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
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