Skip to content

Implement Toast Notification System#64

Merged
jamliaoo merged 22 commits into
mainfrom
feat/toast
Aug 16, 2025
Merged

Implement Toast Notification System#64
jamliaoo merged 22 commits into
mainfrom
feat/toast

Conversation

@jamliaoo

@jamliaoo jamliaoo commented Jun 24, 2025

Copy link
Copy Markdown
Collaborator

Description

This pull request introduces a toast notification system. The goal is to provide a simple and robust way to display short, non-intrusive messages to the user, such as success confirmations or error alerts.
The implementation is heavily inspired by the clean API and singleton pattern of libraries like sonner, but is built from scratch to ensure full control over behavior, styling, and accessibility.

Implementation Details

  1. store.js: A lightweight, global, singleton store that holds the state of the currently active toast. This ensures that only one toast is ever present on the screen, as new toasts simply overwrite the previous state.
  2. toast.js: A public-facing API module that exposes simple functions like toast.success('Message') and toast.error('Message'). Components can call these functions from anywhere in the application without needing to manage state themselves.
  3. toaster.js: A React component that subscribes to the store and is responsible for rendering the toast's UI, managing enter/leave animations (via Headless UI), handling timers for auto-dismissal, and pausing on hover. It is integrated into the main Layout.js to be present on all pages.

Special attention has been given to accessibility, with the component using role="status" and aria-live="polite" to ensure it is properly announced by screen readers.

How to Test

  1. Run the test suite with npm test. All tests for the toaster.test.js suite should pass.
  2. Run the application with npm start. Trigger toasts from various places to manually verify their appearance, behavior, and animations. You can temporarily add buttons in home.js for testing:
import { toast } from '@/lib/toast';

<button onClick={() => toast.success('Success!')}>Success</button>
<button onClick={() => toast.error('Error!', { showCloseButton: true })}>Error</button>

Notes

  • UI/Styling is temporary: The current visual appearance of the toasts is preliminary. A follow-up task will be created to refine the UI to match the project's design system.

Known Issues (Resolved)

  • Known Test Warnings: The test suite for this component passes, but you will see act(...) warnings in the console. My current guess is that these are caused by complex timing interactions between React 18, Jest's fake timers, and the Headless UI animation library, but I'm not entirely certain. After some research, I haven't yet found a stable solution. I'm very open to any suggestions or ideas on this matter. Thanks!

jamliaoo added 5 commits June 24, 2025 15:45
Introduce a global store for managing toast state and a public `toast()` API for triggering notifications from anywhere in the app.
Create the <Toaster /> component which subscribes to the toast store and renders the notifications.

Includes:
- Support for different toast types (info, success, etc.)
- Basic styling and layout
- Accessibility props (role="status", aria-live="polite")
Add the <Toaster /> component to the root Layout, making the notification system globally available throughout the application.
Introduce a full test suite for the Toaster component using React Testing Library and Jest.

Key areas covered:
- Initial render state (renders nothing)
- Correct rendering upon `toast()` calls
- Automatic dismissal after duration
- Pause/resume timer on hover
- Manual dismissal via close button
- Ensures only one toast is rendered at a time

Also includes necessary Jest configuration updates to handle SVG imports and path aliases.
@jamliaoo jamliaoo requested a review from WendellLiu June 24, 2025 09:30
Comment thread src/lib/toast.js Outdated
Comment on lines +28 to +37
/**
* @param {string} message The message to show.
* @param {ToastOptions} [options] Optional settings for the toast.
*/
export const toast = (message, options) => showToast('info', message, options);

toast.success = (message, options) => showToast('success', message, options);
toast.error = (message, options) => showToast('error', message, options);
toast.warning = (message, options) => showToast('warning', message, options);
toast.info = (message, options) => showToast('info', message, options);

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.

  1. May I know why we need to support "toast" as a function?
  2. Could we define it in a declarative way?
const toast = { success: () => {}, ... }

// or
export const success = () => {};
export const error = () => {};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

In the latest implementation, I've renamed it to showToast which is an object with methods (showToast.success(), showToast.error(), etc.) to avoid naming conflicts with the Toast component.

Comment thread src/components/core/toast/index.js Outdated
Comment thread src/components/core/toast/store.js
Comment thread src/lib/toast.js Outdated
* @param {ToastOptions} [options] Optional settings for the toast.
*/
const showToast = (type, message, options = {}) => {
store.update({

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.

Could we handle the default value of ToastState in store instead of this as a user?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

As create-store.js and toast logic have been decoupled, default value handling will be skipped here for the time being.

Comment thread src/components/core/toast/store.js Outdated
Comment thread src/components/core/toast/store.js Outdated
/** @type {Set<Function>} */
const listeners = new Set();

const store = {

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.

make it constructable

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Refactored store.js to create-store.js, making it constructable through a function. Moved this file to the lib directory.

jamliaoo added 14 commits July 13, 2025 14:21
Change the toast function to an object with methods for each toast type.
This improves organization and allows for easier extension in the future.

- Updated toast to be an object with methods: info, success, error, warning.
- Maintained existing functionality for showing toasts.
Replace the existing toast store with a new generic store implementation
that provides subscribe, getState, update, and reset methods. This change
enhances the modularity and reusability of the store logic.

- Introduced createStore function for generic store creation.
- Updated toast-related components to use the new toastStore.
- Removed the old store implementation.
Add a validation function to ensure toast state adheres to expected
structure and defaults. This includes checks for required fields,
valid types, and default values for properties like message, type,
duration, and showCloseButton.

- Introduced DEFAULT_TOAST_VALUES for default state.
- Implemented validateToastState function for state validation.
- Updated toastStore's update method to use validated state.
- Update Toaster component to manage multiple toasts using the new structure
- Replace the old toast store implementation with a new Toast component
- Update tests to verify rendering of multiple toasts simultaneously
- Implement checks for limiting visible toasts to MAX_VISIBLE_TOASTS
- Ensure individual toast timers can be paused and resumed on hover
- Add functionality for dismissing toasts via close buttons
- Refactor existing tests for clarity and improved coverage
- Update showToast to be an object with methods for each toast type
- Modify Toaster tests to use the new showToast structure
- Ensure existing functionality for displaying toasts is maintained
- Upgrade @testing-library/react to version 14.3.1 in package.json and package-lock.json
- Upgrade @testing-library/dom to version 9.3.4 in package-lock.json
- Modify import statements in toaster.test.js for improved clarity
- Ensure compatibility with updated testing library versions
@jamliaoo

jamliaoo commented Aug 4, 2025

Copy link
Copy Markdown
Collaborator Author

Refactored toast system architecture

Key changes:

  • Multi-toast support: Updated architecture to handle multiple simultaneous toasts with queue management
  • Extracted create-store utility: Separated generic store logic from toast-specific implementation
  • Enhanced store pattern: Made toast store constructable through factory function for better modularity

Comment thread src/components/core/toast/toast.js Outdated
// After animation duration, remove from queue
setTimeout(() => {
onRemove(id);
}, 300); // Match the transition duration

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.

Let's define it as a constant(making it in this file is fine)

Comment thread src/components/core/toast/store.js Outdated
});

// Counter to ensure unique IDs
let idCounter = 0;

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.

Comment thread src/components/core/toast/toast.js Outdated

const handleDismiss = useCallback(() => {
// Mark as exiting to start fade out animation
markToastAsExiting(id);

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.

Could we also pass markToastAsExiting as a prop instead of importing it from the store module?

- Replace counter-based ID generation with an infinite generator function
- Ensure unique IDs are generated consistently for each new toast
- Add PropTypes for toast component to enforce type checking
- Introduce onRemoveStart callback to handle toast exit animation
- Update Toaster component to pass onRemoveStart to Toast
@jamliaoo

Copy link
Copy Markdown
Collaborator Author

@WendellLiu Thank you for your review! I've implemented all three suggested changes. Please let me know if there's anything else that needs adjustment.

@WendellLiu WendellLiu left a comment

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.

One only comment

// Generate unique ID using counter + timestamp
const now = Date.now();
const uniqueId = now + ++idCounter;
const uniqueId = idGenerator.next().value;

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.

You created the uniqueId by concatenating a timestamp with an increment number. We could still implement it as a generator. What do you think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Initially I used Date.now() as ID but added an incrementing counter to avoid collisions. After your generator suggestion, I realized that for toast's short-lived nature, simple incrementing IDs are sufficient and more readable. Since toasts disappear on page reload anyway, cross-session uniqueness isn't needed.
I'd prefer to keep the current approach for simplicity unless you see specific benefits of integrating timestamp into the ID generation?

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.

I'm comfortable with both options; I just need to confirm your approach. Thank you for your response.

@jamliaoo jamliaoo merged commit 74f4271 into main Aug 16, 2025
1 check passed
@jamliaoo jamliaoo deleted the feat/toast branch August 16, 2025 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants