Show one confirmation toast, not two

Every page wraps itself in AppLayout, so a flashed redirect that lands on
a different page component tears the layout down and builds it again --
Toaster with it. The fresh Toaster then reads the flash at mount *and*
catches the router success event for the same visit, and every "Client
created." arrived twice. Saves that stay on the same component never
remount, which is why this survived unnoticed.

Deduping on the flash object's identity rather than its text is what
keeps the success listener doing its job: two genuine identical messages
in a row are separate objects and still both toast.

Verified in a real browser rather than by types: create a client, two
toasts before, one after, and two consecutive creates over SPA
navigation still toast once each.

Reported and diagnosed by @denkfabrik-li in #1675.
This commit is contained in:
ignacionelson
2026-08-23 23:44:07 -03:00
parent f7d6fe929e
commit 4f38c9adee
2 changed files with 33 additions and 6 deletions
+8
View File
@@ -15,6 +15,14 @@ a version is cut.
### Fixed
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
nothing happened twice — but it read as though something had, which is the last thing a
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
affected.
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
+25 -6
View File
@@ -15,11 +15,26 @@ interface Toast {
const DURATION = 4500;
/**
* The flash object most recently turned into toasts, across remounts.
*
* Both of the component's sources can hand over the *same* visit's flash:
* the layout (and the Toaster inside it) remounts whenever a flashed
* redirect lands on a different page component — create → edit, delete →
* index — and then the mount-time read and the router `success` event
* each fire once for one flash, stacking every "Client created." twice.
* Identity comparison is the dedup that cannot over-trigger: a repeat of
* the same action produces an identical *message* but never the identical
* *object*, so deliberate back-to-back toasts still both show.
*/
let shownFlash: SharedData['flash'] | null = null;
/**
* App-wide flash toasts. Reads the `flash` shared prop and shows a toast
* after any Inertia visit that carried one (created/updated/deleted…).
* Uses the router `success` event so two identical messages in a row still
* each toast. Mounted once in the app layout.
* each toast. Mounted in the app layout, which remounts it — see the note on
* `shownFlash` above.
*/
export function Toaster() {
const { t } = useTranslation();
@@ -36,16 +51,20 @@ export function Toaster() {
const dismiss = (id: number) => setToasts((current) => current.filter((toast) => toast.id !== id));
const pushFlash = (flash: SharedData['flash'] | null | undefined) => {
if (!flash || flash === shownFlash) return;
shownFlash = flash;
push('success', flash.success);
push('error', flash.error);
};
useEffect(() => {
// A flash present on the very first render (e.g. a server redirect on load).
push('success', page.props.flash?.success);
push('error', page.props.flash?.error);
pushFlash(page.props.flash);
// And every subsequent successful visit.
const stop = router.on('success', (event) => {
const flash = (event.detail.page.props as unknown as SharedData).flash;
push('success', flash?.success);
push('error', flash?.error);
pushFlash((event.detail.page.props as unknown as SharedData).flash);
});
return () => stop();