Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-keys-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@heroui/toast": patch
---

Support multiple ToastProvider with unique IDs
2 changes: 2 additions & 0 deletions apps/docs/content/components/toast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import placement from "./placement";
import usage from "./usage";
import customCloseIcon from "./custom-close-icon";
import close from "./close";
import multipleToastProvider from "./multiple-ToastProvider";

export const toastContent = {
color,
Expand All @@ -16,4 +17,5 @@ export const toastContent = {
placement,
usage,
customCloseIcon,
multipleToastProvider,
};
42 changes: 42 additions & 0 deletions apps/docs/content/components/toast/multiple-ToastProvider.raw.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {addToast, Button, ToastProvider} from "@heroui/react";
import React from "react";

export default function App() {
const topToasterId = "multi-toaster-top";
const bottomToasterId = "multi-toaster-bottom";

return (
<>
<div className="fixed z-[100]">
<ToastProvider placement="top-right" toastOffset={60} toasterId={topToasterId} />
<ToastProvider placement="bottom-right" toasterId={bottomToasterId} />
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="flat"
onPress={() => {
addToast({
title: "Top Toast Title",
description: "Toast Displayed Successfully",
toasterId: topToasterId,
});
}}
>
Show top toast
</Button>
<Button
variant="flat"
onPress={() => {
addToast({
title: "Bottom Toast Title",
description: "Toast Displayed Successfully",
toasterId: bottomToasterId,
});
}}
>
Show bottom toast
</Button>
</div>
</>
);
}
9 changes: 9 additions & 0 deletions apps/docs/content/components/toast/multiple-ToastProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import App from "./multiple-ToastProvider.raw.jsx?raw";

const react = {
"/App.jsx": App,
};

export default {
...react,
};
9 changes: 9 additions & 0 deletions apps/docs/content/docs/components/toast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ You can pass a custom close icon to the toast by passing the `closeIcon` prop an
files={toastContent.customCloseIcon}
/>

### Multiple ToastProvider

You can have multiple ToastProvider with different configurations and use `toasterId` to distinguish them. By passing `toasterId` to `addToast` function, you can add a toast to the corresponding `ToastProvider`.

<CodeDemo
title="Close"
files={toastContent.multipleToastProvider}
/>

### Global Toast Props

You can pass global toast props to the `ToastProvider` to apply to all toasts.
Expand Down
71 changes: 71 additions & 0 deletions packages/components/toast/__tests__/toast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,75 @@ describe("Toast", () => {

expect(loadingIcon).toBeTruthy();
});

it("should work with multiple ToastProvider", async () => {
const leftToasterId = "left";
const leftTitle = "Left Toast Title";
const leftDescription = "Left Toast Description";

const rightToasterId = "right";
const rightTitle = "Right Toast Title";
const rightDescription = "Right Toast Description";

render(
<>
<ToastProvider placement="bottom-left" toasterId={leftToasterId} />
<ToastProvider placement="bottom-right" toasterId={rightToasterId} />
<button
data-testid="left-button"
onClick={() => {
addToast({
title: leftTitle,
description: leftDescription,
toasterId: leftToasterId,
});
}}
>
Show Left Toast
</button>
<button
data-testid="right-button"
onClick={() => {
addToast({
title: rightTitle,
description: rightDescription,
toasterId: rightToasterId,
});
}}
>
Show Right Toast
</button>
</>,
);

const leftButton = screen.getByTestId("left-button");
const rightButton = screen.getByTestId("right-button");

await user.click(leftButton);
await user.click(rightButton);
const regions = await screen.findAllByRole("region");

expect(regions).toHaveLength(2);

const leftRegion = regions.find(
(region) => region.getAttribute("data-placement") === "bottom-left",
);
const rightRegion = regions.find(
(region) => region.getAttribute("data-placement") === "bottom-right",
);

// check for left ToastProvider
expect(leftRegion).toHaveAttribute("data-placement", "bottom-left");
expect(leftRegion).toContainHTML(leftTitle);
expect(leftRegion).toContainHTML(leftDescription);
expect(leftRegion).not.toContainHTML(rightTitle);
expect(leftRegion).not.toContainHTML(rightDescription);

// check for right ToastProvider
expect(rightRegion).toHaveAttribute("data-placement", "bottom-right");
expect(rightRegion).toContainHTML(rightTitle);
expect(rightRegion).toContainHTML(rightDescription);
expect(rightRegion).not.toContainHTML(leftTitle);
expect(rightRegion).not.toContainHTML(leftDescription);
});
});
50 changes: 32 additions & 18 deletions packages/components/toast/src/toast-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {ToastRegion} from "./toast-region";

const loadFeatures = () => import("framer-motion").then((res) => res.domMax);

let globalToastQueue: ToastQueue<ToastProps> | null = null;
const defaultToasterId = "heroui";
let globalToastQueues: Record<string, ToastQueue<ToastProps>> = {};

interface ToastProviderProps {
maxVisibleToasts?: number;
Expand All @@ -19,27 +20,33 @@ interface ToastProviderProps {
toastProps?: ToastProps;
toastOffset?: number;
regionProps?: RegionProps;
toasterId?: string;
}

export const getToastQueue = () => {
if (!globalToastQueue) {
globalToastQueue = new ToastQueue({
export function getToastQueue(): ToastQueue<ToastProps>;
export function getToastQueue(args: {toasterId: string}): ToastQueue<ToastProps>;
export function getToastQueue(args?: {toasterId: string}) {
const toasterId = args?.toasterId || defaultToasterId;

if (!globalToastQueues[toasterId]) {
globalToastQueues[toasterId] = new ToastQueue({
maxVisibleToasts: Infinity,
});
}

return globalToastQueue;
};
return globalToastQueues[toasterId];
}

export const ToastProvider = ({
placement = "bottom-right",
disableAnimation: disableAnimationProp = false,
maxVisibleToasts = 3,
toastOffset = 0,
toastProps = {},
toasterId = defaultToasterId,
regionProps,
}: ToastProviderProps) => {
const toastQueue = useToastQueue(getToastQueue());
const toastQueue = useToastQueue(getToastQueue({toasterId}));
const globalContext = useProviderContext();
const disableAnimation = disableAnimationProp ?? globalContext?.disableAnimation ?? false;

Expand All @@ -60,29 +67,36 @@ export const ToastProvider = ({
);
};

export const addToast = ({...props}: ToastProps & ToastOptions) => {
if (!globalToastQueue) {
export const addToast = ({toasterId = defaultToasterId, ...props}: ToastProps & ToastOptions) => {
if (!globalToastQueues[toasterId]) {
return null;
}

return globalToastQueue.add(props);
return globalToastQueues[toasterId].add(props);
};
Comment on lines +70 to 76
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Aug 17, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Dropping toasts when queue doesn’t exist is surprising; lazily create instead

Returning null if the toasterId queue isn’t initialized causes lost toasts when addToast is called before the provider mounts. Prefer lazy creation for resilience.

-export const addToast = ({toasterId = defaultToasterId, ...props}: ToastProps & ToastOptions) => {
-  if (!globalToastQueues[toasterId]) {
-    return null;
-  }
-
-  return globalToastQueues[toasterId].add(props);
-};
+export const addToast = ({toasterId = defaultToasterId, ...props}: ToastProps & ToastOptions) => {
+  if (!globalToastQueues[toasterId]) {
+    getToastQueue(toasterId);
+  }
+  return globalToastQueues[toasterId].add(props);
+};

If you intentionally want to drop toasts for non-existent providers, at least log a dev warning to aid debugging.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/components/toast/src/toast-provider.tsx around lines 66 to 72, the
current addToast returns null when globalToastQueues[toasterId] is missing which
drops toasts; change it to lazily create the queue instead of returning null: if
the queue for toasterId doesn't exist, initialize it (using the same queue
creation/registration logic the provider uses) and then call its add method; if
you prefer to keep dropping behavior, at minimum emit a dev-only warning
indicating the toasterId is missing. Ensure the new initialization uses the
existing factory/registry so behavior matches mounted providers.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is the original logic of addToast. I doubt if I should switch to this. @wingkwong Can you take a look here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


export const closeToast = (key: string) => {
if (!globalToastQueue) {
export function closeToast(key: string): void;
export function closeToast(args: {key: string; toasterId?: string}): void;
export function closeToast(args: string | {key: string; toasterId?: string}) {
const {key, toasterId} =
typeof args === "string"
? {key: args, toasterId: defaultToasterId}
: {key: args.key, toasterId: args.toasterId || defaultToasterId};

if (!globalToastQueues[toasterId]) {
return;
}
globalToastQueue.close(key);
};
globalToastQueues[toasterId].close(key);
}

export const closeAll = () => {
if (!globalToastQueue) {
export const closeAll = (toasterId = defaultToasterId) => {
if (!globalToastQueues[toasterId]) {
return;
}

const keys = globalToastQueue.visibleToasts.map((toast) => toast.key);
const keys = globalToastQueues[toasterId].visibleToasts.map((toast) => toast.key);

keys.map((key) => {
globalToastQueue?.close(key);
globalToastQueues[toasterId]?.close(key);
});
};
5 changes: 5 additions & 0 deletions packages/components/toast/src/use-toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export interface ToastProps extends ToastVariantProps {
* @default "default"
*/
severity?: "default" | "primary" | "secondary" | "success" | "warning" | "danger";
/**
* The id of the ToastProvider. The decides which ToastProvider will handle the toast.
* @default "heroui"
*/
toasterId?: string;
}

interface Props<T> extends Omit<HTMLHeroUIProps<"div">, "title">, ToastProps {
Expand Down
53 changes: 53 additions & 0 deletions packages/components/toast/stories/toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,52 @@ const CloseToastTemplate = (args: ToastProps) => {
);
};

const MultiToasterTemplate = (args) => {
const topToasterId = "multi-toaster-top";
const bottomToasterId = "multi-toaster-bottom";

return (
<>
<ToastProvider
maxVisibleToasts={args.maxVisibleToasts}
placement="top-right"
toasterId={topToasterId}
/>
<ToastProvider
maxVisibleToasts={args.maxVisibleToasts}
placement="bottom-right"
toasterId={bottomToasterId}
/>
<div className="flex flex-wrap gap-2">
<Button
onPress={() => {
addToast({
title: "Top Toast Title",
description: "Toast Displayed Successfully",
...args,
toasterId: topToasterId,
});
}}
>
Show Top Toast
</Button>
<Button
onPress={() => {
addToast({
title: "Bottom Toast Title",
description: "Toast Displayed Successfully",
...args,
toasterId: bottomToasterId,
});
}}
>
Show Bottom Toast
</Button>
</div>
</>
);
};

export const Default = {
render: Template,
args: {
Expand Down Expand Up @@ -528,3 +574,10 @@ export const CloseToast = {
...defaultProps,
},
};

export const MultiToaster = {
render: MultiToasterTemplate,
args: {
...defaultProps,
},
};