Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
50 changes: 48 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ const Toast = (props: ToastProps) => {
[toast.closeButton, closeButtonFromToaster],
);
const duration = React.useMemo(
() => toast.duration || durationFromToaster || TOAST_LIFETIME,
[toast.duration, durationFromToaster],
() => (toast.persistent ? Infinity : toast.duration || durationFromToaster || TOAST_LIFETIME),
[toast.duration, durationFromToaster, toast.persistent],
);
const closeTimerStartTimeRef = React.useRef(0);
const offset = React.useRef(0);
Expand Down Expand Up @@ -609,6 +609,7 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
gap = GAP,
icons,
containerAriaLabel = 'Notifications',
storageKey = 'sonner-toasts',
} = props;
const [toasts, setToasts] = React.useState<ToastT[]>([]);
const possiblePositions = React.useMemo(() => {
Expand Down Expand Up @@ -676,6 +677,51 @@ const Toaster = React.forwardRef<HTMLElement, ToasterProps>(function Toaster(pro
});
}, [toasts]);

React.useEffect(() => {
if (typeof window !== 'undefined') {
const storedToasts = localStorage.getItem(storageKey);
if (storedToasts) {
try {
const persistentToasts = JSON.parse(storedToasts);

if (Array.isArray(persistentToasts) && persistentToasts.length) {
//Add in reverse order to preserve original order
let i = 0;
let numPersistentToasts = persistentToasts.length;
persistentToasts.reverse().forEach((persistedToast) => {
const toastData = { ...persistedToast, persistent: true, id: i + 1 };
setTimeout(
() => {
// Re-add through ToastState so animations / logic stay consistent
ToastState.addToast(toastData);
},
// Add ramping delay only for visibleToasts
i > numPersistentToasts - visibleToasts ? (i - (numPersistentToasts - visibleToasts)) * 200 : 0,
);
i++;
});
// Set the toast count to the number of persistent toasts
ToastState.setToastCount(numPersistentToasts);
Copy link
Author

@Erik-Koning Erik-Koning Jul 31, 2025

Choose a reason for hiding this comment

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

setToastCount runs before any addToast timeouts finish

}
} catch (error) {
console.error('Failed to parse stored toasts:', error);
localStorage.removeItem(storageKey);
}
}
}
}, []);

React.useEffect(() => {
if (typeof window !== 'undefined') {
const persistentToasts = toasts.filter((toast) => toast.persistent);
if (persistentToasts.length > 0) {
localStorage.setItem(storageKey, JSON.stringify(persistentToasts));
} else {
localStorage.removeItem(storageKey);
}
}
}, [toasts]);

React.useEffect(() => {
if (theme !== 'system') {
setActualTheme(theme);
Expand Down
14 changes: 9 additions & 5 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ import type {

import React from 'react';

let toastsCounter = 1;

type titleT = (() => React.ReactNode) | React.ReactNode;

class Observer {
subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>;
toasts: Array<ToastT | ToastToDismiss>;
dismissedToasts: Set<string | number>;
toastCounter: number;

constructor() {
this.subscribers = [];
this.toasts = [];
this.dismissedToasts = new Set();
this.toastCounter = 1;
}

// We use arrow functions to maintain the correct `this` reference
Expand Down Expand Up @@ -53,7 +53,7 @@ class Observer {
},
) => {
const { message, ...rest } = data;
const id = typeof data?.id === 'number' || data.id?.length > 0 ? data.id : toastsCounter++;
const id = typeof data?.id === 'number' || data.id?.length > 0 ? data.id : this.toastCounter++;
const alreadyExists = this.toasts.find((toast) => {
return toast.id === id;
});
Expand Down Expand Up @@ -98,6 +98,10 @@ class Observer {
return id;
};

setToastCount = (count: number) => {
this.toastCounter = count + 1;
};

message = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, message });
};
Expand Down Expand Up @@ -241,7 +245,7 @@ class Observer {
};

custom = (jsx: (id: number | string) => React.ReactElement, data?: ExternalToast) => {
const id = data?.id || toastsCounter++;
const id = data?.id || this.toastCounter++;
this.create({ jsx: jsx(id), id, ...data });
return id;
};
Expand All @@ -255,7 +259,7 @@ export const ToastState = new Observer();

// bind this to the toast function
const toastFunction = (message: titleT, data?: ExternalToast) => {
const id = data?.id || toastsCounter++;
const id = data?.id || ToastState.toastCounter++;

ToastState.addToast({
title: message,
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface ToastT {
classNames?: ToastClassnames;
descriptionClassName?: string;
position?: Position;
persistent?: boolean;
}

export function isAction(action: Action | React.ReactNode): action is Action {
Expand Down Expand Up @@ -142,6 +143,7 @@ export interface ToasterProps {
swipeDirections?: SwipeDirection[];
icons?: ToastIcons;
containerAriaLabel?: string;
storageKey?: string;
}

export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left';
Expand Down
33 changes: 32 additions & 1 deletion test/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,6 @@ export default function Home({ searchParams }: any) {
>
Extended Promise Toast
</button>


<button
data-testid="extended-promise-error"
Expand Down Expand Up @@ -281,6 +280,38 @@ export default function Home({ searchParams }: any) {
>
Extended Promise Error Toast
</button>
<button
data-testid="persistent-toast"
className="button"
onClick={() => toast('Persistent Toast', { persistent: true, closeButton: true })}
>
Persistent toast
</button>
<button
data-testid="persistent-extended-promise-toast"
className="button"
onClick={() =>
toast.promise(
new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'Sonner' });
}, 2000);
}),
{
loading: 'Loading...',
success: (data: any) => ({
message: `${data.name} toast has been added`,
description: 'Custom description for the Success state',
}),
description: 'Global description',
persistent: true,
closeButton: true,
},
)
}
>
Persistent Promise Toast
</button>
<button
data-testid="error-promise"
className="button"
Expand Down
47 changes: 47 additions & 0 deletions test/tests/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,53 @@ test.describe('Basic functionality', () => {
await expect(page.getByText('Error Raise: Error: Not implemented')).toHaveCount(1);
});

test('should persist a toast after a page reload', async ({ page }) => {
// Create a persistent toast
const persistentToastButton = page.locator('[data-testid="persistent-toast"]');
await persistentToastButton.click();

// Verify the toast is visible
const toast = page.locator('[data-sonner-toast]');
await expect(toast).toBeVisible();
await expect(toast).toContainText('Persistent Toast');

// Reload the page
await page.reload();

// Verify the toast is still visible after reload
const restoredToast = page.locator('[data-sonner-toast]');
await expect(restoredToast).toBeVisible();
await expect(restoredToast).toContainText('Persistent Toast');

// Verify the toast can be dismissed
const closeButton = restoredToast.locator('[data-close-button]');
await expect(closeButton).toBeVisible();
await closeButton.click();

// Verify the toast is no longer visible
await expect(restoredToast).not.toBeVisible();

// Reload again to verify the toast doesn't come back after being dismissed
await page.reload();
await expect(page.locator('[data-sonner-toast]')).not.toBeVisible();
});

test('should not persist regular (non-persistent) toasts', async ({ page }) => {
// Create a regular toast
const regularToastButton = page.locator('[data-testid="default-button"]');
await regularToastButton.click();

// Verify the toast is visible
const toast = page.locator('[data-sonner-toast]');
await expect(toast).toBeVisible();

// Reload the page
await page.reload();

// Verify the regular toast does not persist
await expect(page.locator('[data-sonner-toast]')).not.toBeVisible();
});

test('render custom jsx in toast', async ({ page }) => {
await page.getByTestId('custom').click();
await expect(page.getByText('jsx')).toHaveCount(1);
Expand Down