diff --git a/site/components/docs-layout.tsx b/site/components/docs-layout.tsx index a88a85b..4269d50 100644 --- a/site/components/docs-layout.tsx +++ b/site/components/docs-layout.tsx @@ -75,6 +75,8 @@ export default function DocsLayout({ meta, children }) { Guides Styling + Multi Toaster + Releases New in 2.0 diff --git a/site/pages/docs/multi-toaster.mdx b/site/pages/docs/multi-toaster.mdx new file mode 100644 index 0000000..2f60969 --- /dev/null +++ b/site/pages/docs/multi-toaster.mdx @@ -0,0 +1,67 @@ +import Layout from '../../components/docs-layout'; +import toast, { Toaster } from 'react-hot-toast'; + +export const meta = { + title: 'Multiple Toasters', +}; + +export default ({ children }) => {children}; + +# Multiple Toasters + +React Hot Toast supports having multiple toaster instances in your app. This allows you to have different toast configurations and positions for different parts of your application. + +## Basic Usage + +You can create multiple toasters by giving each one a unique `toasterId`: + +```jsx + + + + +// Create a toast in area 1 +toast('Notification for Area 1', { toasterId: 'area1' }) +``` + +{/* Global toaster */} + +
+
+

Area 1

+ +
+ +
+
+ +
+

Area 2

+ +
+ +
+ +
+
diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx index 67c07e7..1b36ddb 100644 --- a/src/components/toaster.tsx +++ b/src/components/toaster.tsx @@ -88,14 +88,15 @@ export const Toaster: React.FC = ({ toastOptions, gutter, children, + toasterId, containerStyle, containerClassName, }) => { - const { toasts, handlers } = useToaster(toastOptions); + const { toasts, handlers } = useToaster(toastOptions, toasterId); return (
{ +interface State { + [toasterId: string]: ToasterState; +} + +export const reducer = (state: ToasterState, action: Action): ToasterState => { + const { toastLimit } = state.settings; + switch (action.type) { case ActionType.ADD_TOAST: return { ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + toasts: [action.toast, ...state.toasts].slice(0, toastLimit), }; case ActionType.UPDATE_TOAST: @@ -120,17 +133,45 @@ export const reducer = (state: State, action: Action): State => { } }; -const listeners: Array<(state: State) => void> = []; +const listeners: Array< + [toasterId: string, reducer: (state: ToasterState) => void] +> = []; -let memoryState: State = { toasts: [], pausedAt: undefined }; - -export const dispatch = (action: Action) => { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); +const defaultToasterState: ToasterState = { + toasts: [], + pausedAt: undefined, + settings: { + toastLimit: TOAST_LIMIT, + }, +}; +let memoryState: State = {}; + +export const dispatch = (action: Action, toasterId = DEFAULT_TOASTER_ID) => { + memoryState[toasterId] = reducer( + memoryState[toasterId] || defaultToasterState, + action + ); + listeners.forEach(([id, listener]) => { + if (id === toasterId) { + listener(memoryState[toasterId]); + } }); }; +export const dispatchAll = (action: Action) => + Object.keys(memoryState).forEach((toasterId) => dispatch(action, toasterId)); + +export const getToasterIdFromToastId = (toastId: string) => + Object.keys(memoryState).find((toasterId) => + memoryState[toasterId].toasts.some((t) => t.id === toastId) + ); + +export const createDispatch = + (toasterId = DEFAULT_TOASTER_ID) => + (action: Action) => { + dispatch(action, toasterId); + }; + export const defaultTimeouts: { [key in ToastType]: number; } = { @@ -141,23 +182,28 @@ export const defaultTimeouts: { custom: 4000, }; -export const useStore = (toastOptions: DefaultToastOptions = {}): State => { - const [state, setState] = useState(memoryState); - const initial = useRef(memoryState); +export const useStore = ( + toastOptions: DefaultToastOptions = {}, + toasterId: string = DEFAULT_TOASTER_ID +): ToasterState => { + const [state, setState] = useState( + memoryState[toasterId] || defaultToasterState + ); + const initial = useRef(memoryState[toasterId]); // TODO: Switch to useSyncExternalStore when targeting React 18+ useEffect(() => { - if (initial.current !== memoryState) { - setState(memoryState); + if (initial.current !== memoryState[toasterId]) { + setState(memoryState[toasterId]); } - listeners.push(setState); + listeners.push([toasterId, setState]); return () => { - const index = listeners.indexOf(setState); + const index = listeners.findIndex(([id]) => id === toasterId); if (index > -1) { listeners.splice(index, 1); } }; - }, []); + }, [toasterId]); const mergedToasts = state.toasts.map((t) => ({ ...toastOptions, diff --git a/src/core/toast.ts b/src/core/toast.ts index 2be7811..c8ef188 100644 --- a/src/core/toast.ts +++ b/src/core/toast.ts @@ -8,7 +8,13 @@ import { resolveValue, } from './types'; import { genId } from './utils'; -import { dispatch, ActionType } from './store'; +import { + createDispatch, + Action, + ActionType, + dispatchAll, + getToasterIdFromToastId, +} from './store'; type Message = ValueOrFunction; @@ -37,6 +43,11 @@ const createHandler = (type?: ToastType): ToastHandler => (message, options) => { const toast = createToast(message, type, options); + + const dispatch = createDispatch( + toast.toasterId || getToasterIdFromToastId(toast.id) + ); + dispatch({ type: ActionType.UPSERT_TOAST, toast }); return toast.id; }; @@ -49,16 +60,53 @@ toast.success = createHandler('success'); toast.loading = createHandler('loading'); toast.custom = createHandler('custom'); -toast.dismiss = (toastId?: string) => { - dispatch({ +/** + * Dismisses the toast with the given id. If no id is given, dismisses all toasts. + * The toast will transition out and then be removed from the DOM. + * Applies to all toasters, except when a `toasterId` is given. + */ +toast.dismiss = (toastId?: string, toasterId?: string) => { + const action: Action = { type: ActionType.DISMISS_TOAST, toastId, - }); + }; + + if (toasterId) { + createDispatch(toasterId)(action); + } else { + dispatchAll(action); + } +}; + +/** + * Dismisses all toasts. + */ +toast.dismissAll = (toasterId?: string) => toast.dismiss(undefined, toasterId); + +/** + * Removes the toast with the given id. + * The toast will be removed from the DOM without any transition. + */ +toast.remove = (toastId?: string, toasterId?: string) => { + const action: Action = { + type: ActionType.REMOVE_TOAST, + toastId, + }; + if (toasterId) { + createDispatch(toasterId)(action); + } else { + dispatchAll(action); + } }; -toast.remove = (toastId?: string) => - dispatch({ type: ActionType.REMOVE_TOAST, toastId }); +/** + * Removes all toasts. + */ +toast.removeAll = (toasterId?: string) => toast.remove(undefined, toasterId); +/** + * Create a loading toast that will automatically updates with the promise. + */ toast.promise = ( promise: Promise | (() => Promise), msgs: { diff --git a/src/core/types.ts b/src/core/types.ts index 764a5d7..2e668ee 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -34,6 +34,7 @@ export const resolveValue = ( export interface Toast { type: ToastType; id: string; + toasterId?: string; message: ValueOrFunction; icon?: Renderable; duration?: number; @@ -67,6 +68,7 @@ export type ToastOptions = Partial< | 'style' | 'position' | 'iconTheme' + | 'toasterId' | 'removeDelay' > >; @@ -82,6 +84,7 @@ export interface ToasterProps { gutter?: number; containerStyle?: React.CSSProperties; containerClassName?: string; + toasterId?: string; children?: (toast: Toast) => React.ReactElement; } diff --git a/src/core/use-toaster.ts b/src/core/use-toaster.ts index 5fb6e03..c97a0f1 100644 --- a/src/core/use-toaster.ts +++ b/src/core/use-toaster.ts @@ -1,43 +1,37 @@ -import { useEffect, useCallback } from 'react'; -import { dispatch, ActionType, useStore } from './store'; +import { useEffect, useCallback, useRef } from 'react'; +import { createDispatch, ActionType, useStore, dispatch } from './store'; import { toast } from './toast'; import { DefaultToastOptions, Toast, ToastPosition } from './types'; -const updateHeight = (toastId: string, height: number) => { - dispatch({ - type: ActionType.UPDATE_TOAST, - toast: { id: toastId, height }, - }); -}; -const startPause = () => { - dispatch({ - type: ActionType.START_PAUSE, - time: Date.now(), - }); -}; - -const toastTimeouts = new Map>(); - export const REMOVE_DELAY = 1000; -const addToRemoveQueue = (toastId: string, removeDelay = REMOVE_DELAY) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: ActionType.REMOVE_TOAST, - toastId: toastId, - }); - }, removeDelay); +export const useToaster = ( + toastOptions?: DefaultToastOptions, + toasterId: string = 'default' +) => { + const { toasts, pausedAt } = useStore(toastOptions, toasterId); + const toastTimeouts = useRef( + new Map>() + ).current; + + const addToRemoveQueue = useCallback( + (toastId: string, removeDelay = REMOVE_DELAY) => { + if (toastTimeouts.has(toastId)) { + return; + } - toastTimeouts.set(toastId, timeout); -}; + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: ActionType.REMOVE_TOAST, + toastId: toastId, + }); + }, removeDelay); -export const useToaster = (toastOptions?: DefaultToastOptions) => { - const { toasts, pausedAt } = useStore(toastOptions); + toastTimeouts.set(toastId, timeout); + }, + [] + ); useEffect(() => { if (pausedAt) { @@ -59,19 +53,38 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { } return; } - return setTimeout(() => toast.dismiss(t.id), durationLeft); + return setTimeout(() => toast.dismiss(t.id, toasterId), durationLeft); }); return () => { timeouts.forEach((timeout) => timeout && clearTimeout(timeout)); }; - }, [toasts, pausedAt]); + }, [toasts, pausedAt, toasterId]); + + const dispatch = useCallback(createDispatch(toasterId), [toasterId]); + + const startPause = useCallback(() => { + dispatch({ + type: ActionType.START_PAUSE, + time: Date.now(), + }); + }, [dispatch]); + + const updateHeight = useCallback( + (toastId: string, height: number) => { + dispatch({ + type: ActionType.UPDATE_TOAST, + toast: { id: toastId, height }, + }); + }, + [dispatch] + ); const endPause = useCallback(() => { if (pausedAt) { dispatch({ type: ActionType.END_PAUSE, time: Date.now() }); } - }, [pausedAt]); + }, [pausedAt, dispatch]); const calculateOffset = useCallback( ( @@ -104,8 +117,8 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { [toasts] ); + // Keep track of dismissed toasts and remove them after the delay useEffect(() => { - // Add dismissed toasts to remove queue toasts.forEach((toast) => { if (toast.dismissed) { addToRemoveQueue(toast.id, toast.removeDelay); @@ -118,7 +131,7 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { } } }); - }, [toasts]); + }, [toasts, addToRemoveQueue]); return { toasts, diff --git a/test/toast.test.tsx b/test/toast.test.tsx index a79d689..dea5c8e 100644 --- a/test/toast.test.tsx +++ b/test/toast.test.tsx @@ -341,3 +341,194 @@ test('"toast" can be called from useEffect hook', async () => { await screen.findByText(/MyComponent finished/i); expect(screen.queryByText(/Success toast/i)).toBeInTheDocument(); }); + +describe('Multi-Toaster behavior', () => { + test('renders toasts in correct containers and dismisses them individually', () => { + render( + <> + + + + + ); + + // Show three toasts in three different toasters + act(() => { + toast.success('Default toaster message'); + toast.error('Second toaster message', { + toasterId: 'second-toaster', + id: 'second-toast', + }); + toast.loading('Third toaster message', { toasterId: 'third-toaster' }); + }); + + const defaultContainer = document.querySelector('.default-toaster'); + const secondContainer = document.querySelector('.second-toaster'); + const thirdContainer = document.querySelector('.third-toaster'); + + // Ensure each toast is present and in the correct container + expect(defaultContainer).toContainElement( + screen.getByText('Default toaster message') + ); + expect(secondContainer).toContainElement( + screen.getByText('Second toaster message') + ); + expect(thirdContainer).toContainElement( + screen.getByText('Third toaster message') + ); + + // Dismiss only the toast in the second toaster + act(() => { + toast.dismiss('second-toast'); + }); + + waitTime(REMOVE_DELAY); + + expect( + screen.queryByText('Second toaster message') + ).not.toBeInTheDocument(); + expect(screen.queryByText('Default toaster message')).toBeInTheDocument(); + expect(screen.queryByText('Third toaster message')).toBeInTheDocument(); + + // Dismiss all toasts + act(() => { + toast.dismissAll(); + }); + + waitTime(REMOVE_DELAY); + + expect( + screen.queryByText('Default toaster message') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Second toaster message') + ).not.toBeInTheDocument(); + expect(screen.queryByText('Third toaster message')).not.toBeInTheDocument(); + }); + + test('updates a toast in a specific toaster without affecting others', () => { + render( + <> + + + + ); + + let toastId: string; + + // Create a loading toast in the second toaster + act(() => { + toastId = toast.loading('Please wait...', { + toasterId: 'updatable-toaster', + }); + }); + + const secondContainer = document.querySelector('.updatable-toaster'); + expect(secondContainer).toContainElement( + screen.getByText('Please wait...') + ); + + // Now update that toast to success + act(() => { + // Note that we are not providing a toasterId here + toast.success('Data saved!', { + id: toastId, + }); + }); + + // Confirm the updated text + expect(screen.queryByText('Please wait...')).not.toBeInTheDocument(); + expect(secondContainer).toContainElement(screen.getByText('Data saved!')); + }); + + test('dismisses all toasts from a specific toaster and leaves others intact', () => { + render( + <> + + + + ); + + // Create one toast in each toaster + act(() => { + toast.success('Default toaster toast'); + toast.success('Other toaster toast', { toasterId: 'other-toaster' }); + }); + + // Ensure both appear + expect(screen.getByText('Default toaster toast')).toBeInTheDocument(); + expect(screen.getByText('Other toaster toast')).toBeInTheDocument(); + + // Dismiss only the second toaster's toasts + act(() => { + toast.dismissAll('other-toaster'); + }); + waitTime(REMOVE_DELAY); + + // The other toaster's toast should be gone, default remains + expect(screen.queryByText('Other toaster toast')).not.toBeInTheDocument(); + expect(screen.queryByText('Default toaster toast')).toBeInTheDocument(); + }); + + test('dismisses all toasts across all toasters with dismissAll', () => { + render( + <> + + + + ); + + // Create one toast in each toaster + act(() => { + toast.success('Default toaster toast'); + toast.error('Other toaster toast', { toasterId: 'other-toaster' }); + }); + + // Dismiss every toast in all toasters + act(() => { + toast.dismissAll(); + }); + waitTime(REMOVE_DELAY); + + // Both should be removed + expect(screen.queryByText('Default toaster toast')).not.toBeInTheDocument(); + expect(screen.queryByText('Other toaster toast')).not.toBeInTheDocument(); + }); + + test('removes toasts immediately when calling toast.remove()', () => { + render( + <> + + + + ); + + act(() => { + toast.success('Removable toast #1', { + toasterId: 'instant-remove-toaster', + }); + toast.error('Removable toast #2', { toasterId: 'another-toaster' }); + }); + + expect(screen.queryByText('Removable toast #1')).toBeInTheDocument(); + expect(screen.queryByText('Removable toast #2')).toBeInTheDocument(); + + act(() => { + toast.removeAll('instant-remove-toaster'); + }); + + expect(screen.queryByText('Removable toast #1')).not.toBeInTheDocument(); + expect(screen.queryByText('Removable toast #2')).toBeInTheDocument(); + }); +});