Skip to content

ECHOES-1357 Aggregate repeated toasts with a counter badge#693

Open
david-cho-lerat-sonarsource wants to merge 1 commit into
mainfrom
david/ECHOES-1357-aggregate-repeated-toasts
Open

ECHOES-1357 Aggregate repeated toasts with a counter badge#693
david-cho-lerat-sonarsource wants to merge 1 commit into
mainfrom
david/ECHOES-1357-aggregate-repeated-toasts

Conversation

@david-cho-lerat-sonarsource

@david-cho-lerat-sonarsource david-cho-lerat-sonarsource commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Aggregate repeated toast calls when they have the same plain-text description, the same variety, and no explicit id.
  • If the toast also has a visible plain-text title, that title must match too. Description-only toasts aggregate too, and blank plain-text titles are treated like no title.
  • Keep a single visible toast for those repeats, keep the original text unchanged, and show a counter badge inline next to the title, or next to the description when there is no title.
  • Cap the visible badge at 99+ while keeping the full count for screen readers.
  • Clear the internal aggregation tracking when the aggregated toast is dismissed or auto-closed, so a later matching toast starts again from 1.

Notes

  • Reusing an explicit id still follows the existing stable-id update behavior and bypasses automatic same-text aggregation.
  • Toasts with actions, lifecycle callbacks, non-text descriptions, or non-text titles are not automatically aggregated.
  • Storybook includes both the titled and description-only aggregated examples.
  • The public dismiss API remains toast.dismiss(...).

@netlify

netlify Bot commented Jun 4, 2026

Copy link
Copy Markdown

Deploy Preview for echoes-react ready!

Name Link
🔨 Latest commit 37b101f
🔍 Latest deploy log https://app.netlify.com/projects/echoes-react/deploys/6a26fd046c09f30008986b78
😎 Deploy Preview https://deploy-preview-693--echoes-react.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Jun 4, 2026

Copy link
Copy Markdown

ECHOES-1357

Comment thread src/common/components/Toast.tsx Outdated
Comment thread src/common/components/Toast.tsx Outdated
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch 2 times, most recently from 29fc534 to 00d336b Compare June 4, 2026 13:07
Comment thread src/common/components/Toast.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds automatic aggregation for repeated “same text + same variety” toast notifications, showing a repetition counter badge instead of rendering duplicate toasts. It extends the toast utility to track repetition state and refresh the auto-close timer on repeats, while updating the Toast UI to display the new counter in an accessible way.

Changes:

  • Implemented automatic same-text toast aggregation in src/utils/toasts.tsx, including deterministic IDs, repetition tracking, and timer refresh behavior.
  • Updated Toast to accept and render a repetitionCount badge (capped at 99+) with a screen-reader announcement, plus added a new i18n key.
  • Added Storybook coverage and unit tests validating aggregation, variety separation, non-text exclusion, counter capping, and timer reset behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
stories/Toast-stories.tsx Adds an “AggregatedRepeatedToasts” story and aligns controls/defaults to the updated toast behavior.
src/utils/toasts.tsx Core aggregation logic: computes aggregation IDs from plain text, tracks repetition state, refreshes duration, and clears state on dismiss/auto-close.
src/utils/tests/toasts-test.tsx Adds unit tests covering aggregation behavior, counter capping, non-text exclusion, and timer refresh.
src/components/badges/BadgeCounter.tsx Broadens badge props to support standard <span> attributes while keeping value as the rendered content.
src/common/components/Toast.tsx Renders repetition counter badge + screen-reader label, and adjusts trailing layout to accommodate badge + dismiss button.
i18n/keys.json Adds toast.repetition-count message used for the repetition announcement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from c1a873a to 46535ce Compare June 4, 2026 13:29
Comment thread src/utils/toasts.tsx Outdated
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from 46535ce to 48c1d64 Compare June 4, 2026 13:53
Comment thread src/utils/toasts.tsx Outdated
Base automatically changed from david/ECHOES-1339-better-document-toast-stable-id to main June 4, 2026 13:59
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from 48c1d64 to 2a8f2ea Compare June 4, 2026 14:01
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from 2a8f2ea to d2dd6fe Compare June 4, 2026 14:20
Comment thread src/components/badges/BadgeCounter.tsx Outdated
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from d2dd6fe to a707905 Compare June 4, 2026 15:01
Comment thread src/utils/toasts.tsx Outdated
Comment thread src/utils/toasts.tsx Outdated
Comment thread src/utils/toasts.tsx Outdated
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from 5814d6e to 4f0a389 Compare June 8, 2026 13:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread src/utils/__tests__/toasts-aggregation-ui-test.tsx
Comment thread src/utils/toast-internals/repeated-toast-tracking.ts
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from 4f0a389 to 2e07316 Compare June 8, 2026 13:35
Comment thread src/common/components/Toast.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread src/utils/toasts.tsx
@david-cho-lerat-sonarsource david-cho-lerat-sonarsource force-pushed the david/ECHOES-1357-aggregate-repeated-toasts branch from 2e07316 to 1fe96d6 Compare June 8, 2026 13:50
Comment thread src/common/components/Toast.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread src/common/components/Toast.tsx Outdated
Comment thread src/utils/__tests__/toasts-aggregation-ui-test.tsx Outdated

@jeremy-davis-sonarsource jeremy-davis-sonarsource 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.

Why don't we allow aggregation when onDismiss or onAutoClose is defined?

Looks good, apart from the stories not working properly

Comment thread stories/Toast-stories.tsx Outdated
Comment thread stories/Toast-stories.tsx Outdated
variety: ToastVariety.Info,
description: TEST_MESSAGE,
});
trackToastId(

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.

Why track the toast id?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The tests track every created toast id so afterEach can dismiss leftovers deterministically and wait for Sonner’s deferred removal. That keeps the toast tests isolated and avoids state leaking between timer-based cases.

@david-cho-lerat-sonarsource

Copy link
Copy Markdown
Contributor Author

Why don't we allow aggregation when onDismiss or onAutoClose is defined?

I kept lifecycle callbacks as an explicit opt-out because aggregation collapses several toast calls into one visible toast lifecycle. With callbacks, the behavior becomes ambiguous: once per underlying call, once per visible aggregated toast, or only for the last update..? I preferred the explicit opt-out over inventing callback semantics in this PR.

Comment thread src/utils/__tests__/toasts-test.tsx Outdated
Comment thread src/utils/toast-internals/repeated-toast-tracking.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

@sonarqube-next

sonarqube-next Bot commented Jun 8, 2026

Copy link
Copy Markdown

Comment on lines +310 to +320
function hasVisibleToastTitle(title: TextNodeOptional | undefined): boolean {
if (typeof title === 'string') {
return title.trim().length > 0;
}

if (Array.isArray(title)) {
return title.some((child) => hasVisibleToastTitle(child));
}

return Boolean(title);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Title-visibility logic duplicated across two modules

hasVisibleToastTitle in Toast.tsx (lines 310-320) and the title-detection branch of getRepeatedToastKey in repeated-toast-tracking.ts (lines 108-115) independently decide whether a title counts as 'visible plain text'. They use different mechanics: Toast.tsx returns Boolean(title) for non-string/non-array nodes and some() for arrays, while the aggregation path concatenates arrays via getToastPlainText and bails on any non-text node. Today this does not produce a user-visible bug (non-text/numeric titles are never aggregated, so repetitionCount stays undefined and no badge renders regardless of where the layout function would place it). However, the two definitions can drift independently as the title type evolves, and a future change to one (e.g. supporting numeric titles in aggregation) could place the counter badge inconsistently relative to where the title actually renders. Consider extracting a single shared helper for 'visible plain-text title' so layout and aggregation stay in agreement.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 8, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 18 resolved / 19 findings

Implements toast aggregation with counter badges for repeated identical messages, successfully resolving numerous edge cases regarding lifecycle management and state cleanup. Consolidate the duplicated hasVisibleToastTitle logic to improve maintainability.

💡 Quality: Title-visibility logic duplicated across two modules

📄 src/common/components/Toast.tsx:310-320 📄 src/utils/toast-internals/repeated-toast-tracking.ts:105-119

hasVisibleToastTitle in Toast.tsx (lines 310-320) and the title-detection branch of getRepeatedToastKey in repeated-toast-tracking.ts (lines 108-115) independently decide whether a title counts as 'visible plain text'. They use different mechanics: Toast.tsx returns Boolean(title) for non-string/non-array nodes and some() for arrays, while the aggregation path concatenates arrays via getToastPlainText and bails on any non-text node. Today this does not produce a user-visible bug (non-text/numeric titles are never aggregated, so repetitionCount stays undefined and no badge renders regardless of where the layout function would place it). However, the two definitions can drift independently as the title type evolves, and a future change to one (e.g. supporting numeric titles in aggregation) could place the counter badge inconsistently relative to where the title actually renders. Consider extracting a single shared helper for 'visible plain-text title' so layout and aggregation stay in agreement.

✅ 18 resolved
Edge Case: Repetition counter badge has no upper bound

📄 src/common/components/Toast.tsx:159-160 📄 src/common/components/Toast.tsx:200 📄 src/components/badges/BadgeCounter.tsx:30 📄 src/components/badges/BadgeCounter.tsx:37
ToastRepetitionCounter is rendered with value={visibleRepetitionCount}, where visibleRepetitionCount is the raw aggregation count (a number). BadgeCounter renders value verbatim and performs no capping. While BadgeCounter supports overflow strings like '23+', the toast path always passes a plain number, so a toast aggregated a large number of times (e.g. hundreds/thousands of rapid repeats) renders the full number, which can stretch the badge and the toast's trailing content layout. Consider capping the displayed value (e.g. show '99+' when visibleRepetitionCount > 99) before passing it to the badge.

Quality: Screen-reader "Repeated {count} times" is off-by-one vs occurrences

📄 src/common/components/Toast.tsx:188-202
visibleRepetitionCount equals the total number of times the toast was shown (count starts at 1 on first display, becomes 2 on the first repeat, etc.). The badge showing the total occurrence count is intuitive, but the screen-reader message Repeated {count} times. announces the same number, which reads as one more than the number of actual repeats (a toast shown 3 times has only been repeated twice). This is a minor accessibility wording nuance; if the intent is to convey total occurrences, consider wording like "Shown {count} times" to avoid ambiguity, or subtract one for the "repeated" phrasing.

Quality: New i18n key 'toast.repetition-count' missing from i18n/keys.json

📄 src/common/components/Toast.tsx:191-199 📄 src/common/components/Toast.tsx:191-200
The new screen-reader message toast.repetition-count is used via intl.formatMessage in Toast.tsx, but it has not been added to i18n/keys.json, the extracted message catalog used by the translation pipeline. Every other toast message (e.g. toast.dismiss, toast.prefix.*) is registered there. Until the key is extracted (via npm run build-intl-keys) and committed, this string cannot be translated and the consistency check in CI may fail. Run the extraction script and commit the regenerated i18n/keys.json.

Quality: hasActiveToast relies on undocumented sonner internals

📄 src/utils/toasts.tsx:387-401
hasActiveToast (src/utils/toasts.tsx:393-401) inspects sonner's internal toast objects via 'dismiss' in toast ? !toast.dismiss : !toast.delete. These dismiss/delete flags are not part of sonner's public, typed API (sonner 2.0.7) — the ternary itself hints that the field varies across versions. This check is load-bearing for the aggregation feature: it is the only mechanism that resets the counter once a toast disappears. If a future sonner upgrade changes this internal shape, the function could silently always-return-false (counter never increments, badge never shown) or always-return-true (stale counts grow across unrelated occurrences). Consider pinning/asserting the sonner version, adding a regression test against this behavior, or wrapping the access defensively with an explicit fallback so a shape change degrades gracefully rather than silently breaking aggregation.

Quality: Timer refresh relies on undocumented Sonner duration behavior

📄 src/utils/toasts.tsx:365-375
getEffectiveDurationValue deliberately adds a toggling durationOffset (0/1 ms) to the numeric duration so that Sonner restarts the auto-close timer when an aggregated toast is updated. The accompanying comment ("Sonner only restarts the auto-close timer when the numeric duration changes") documents the trick, but it depends on internal/undocumented Sonner behaviour rather than a public API contract. A future Sonner upgrade that changes how duration changes are detected (e.g. comparing object identity, debouncing, or restarting on every update) would silently break timer refresh: aggregated toasts could auto-close based on the first occurrence's timer instead of being extended on each repeat. The existing test (should refresh the auto-close timer when an aggregated toast is updated) is the only guard. Consider pinning the Sonner version range and/or adding a comment referencing the specific Sonner version this behaviour was verified against, so the fragility is visible during dependency bumps.

...and 13 more resolved from earlier reviews

🤖 Prompt for agents
Code Review: Implements toast aggregation with counter badges for repeated identical messages, successfully resolving numerous edge cases regarding lifecycle management and state cleanup. Consolidate the duplicated `hasVisibleToastTitle` logic to improve maintainability.

1. 💡 Quality: Title-visibility logic duplicated across two modules
   Files: src/common/components/Toast.tsx:310-320, src/utils/toast-internals/repeated-toast-tracking.ts:105-119

   `hasVisibleToastTitle` in Toast.tsx (lines 310-320) and the title-detection branch of `getRepeatedToastKey` in repeated-toast-tracking.ts (lines 108-115) independently decide whether a title counts as 'visible plain text'. They use different mechanics: Toast.tsx returns `Boolean(title)` for non-string/non-array nodes and `some()` for arrays, while the aggregation path concatenates arrays via `getToastPlainText` and bails on any non-text node. Today this does not produce a user-visible bug (non-text/numeric titles are never aggregated, so `repetitionCount` stays undefined and no badge renders regardless of where the layout function would place it). However, the two definitions can drift independently as the title type evolves, and a future change to one (e.g. supporting numeric titles in aggregation) could place the counter badge inconsistently relative to where the title actually renders. Consider extracting a single shared helper for 'visible plain-text title' so layout and aggregation stay in agreement.

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

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.

3 participants