From e0fc1e0e7ab7d2172b8bd97fe59f9939c26a60c4 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:51:39 -0400 Subject: [PATCH] Fleet UI: Auto-dismiss success toasts after navigation (#48112) --- .claude/rules/fleet-frontend.md | 6 ++--- .../EmailTokenRedirect/EmailTokenRedirect.tsx | 2 +- .../ToastNotification/ToastNotification.tsx | 8 +++--- frontend/docs/patterns.md | 25 ++++++++----------- .../ConfirmInvitePage/ConfirmInvitePage.tsx | 2 +- .../MdmSettings/AppleMdmPage/AppleMdmPage.tsx | 2 +- .../TeamDetailsWrapper/TeamDetailsWrapper.tsx | 4 +-- .../HostDetailsPage/HostDetailsPage.tsx | 2 +- .../labels/NewLabelPage/NewLabelPage.tsx | 2 +- .../pages/packs/EditPackPage/EditPackPage.tsx | 2 +- .../PackComposerPage/PackComposerPage.tsx | 2 +- .../policies/edit/screens/QueryEditor.tsx | 2 +- frontend/pages/queries/edit/EditQueryPage.tsx | 2 +- 13 files changed, 28 insertions(+), 33 deletions(-) diff --git a/.claude/rules/fleet-frontend.md b/.claude/rules/fleet-frontend.md index 2fdf529585..5a47ec91a0 100644 --- a/.claude/rules/fleet-frontend.md +++ b/.claude/rules/fleet-frontend.md @@ -69,9 +69,9 @@ Use react-router, not `window.location` / `window.history`. Direct window mutati - Internal `` (and any `router.push` / ``) to a fleet-scoped route MUST preserve the current fleet via `getPathWithQueryParams(PATHS.X, { fleet_id: teamId })`. Linking to the bare path drops fleet context and lands the user on the wrong fleet. Applies to any path that reads `fleet_id` from the query string (most `/software`, `/hosts`, `/policies`, `/queries`, `/controls` routes). `getPathWithQueryParams` filters undefined/null, so pass `teamId` directly — `fleet_id=0` (No team) is a valid, intentional value and must be preserved. ## Notifications -- Use `renderFlash(alertType, message)` from `NotificationContext` -- Types: `"success"`, `"error"`, `"warning-filled"` -- Use `renderMultiFlash()` for batch operations +- Use `notify.success(msg)` / `notify.error(msg, { response })` / `notify.batch([...])` from `components/ToastNotification`. +- **When showing a success toast and navigating, call `notify.success` before `router.push` / `router.replace`** — the reverse order can break auto-dismiss on the destination page (#48088). +- Success toasts auto-dismiss after 5s by default; error toasts are sticky by default. ## XSS Prevention - ALWAYS sanitize user-generated HTML before `dangerouslySetInnerHTML`. Approved helpers: diff --git a/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx index c87b5878cf..88fbd292c1 100644 --- a/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx +++ b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx @@ -24,8 +24,8 @@ const EmailTokenRedirect = ({ if (currentUser && token) { try { await usersAPI.confirmEmailChange(currentUser, token); - router.push(PATHS.ACCOUNT); notify.success("Email updated successfully."); + router.push(PATHS.ACCOUNT); } catch (error) { console.log(error); router.push(PATHS.LOGIN); diff --git a/frontend/components/ToastNotification/ToastNotification.tsx b/frontend/components/ToastNotification/ToastNotification.tsx index f2c1d46c88..19c4c788e2 100644 --- a/frontend/components/ToastNotification/ToastNotification.tsx +++ b/frontend/components/ToastNotification/ToastNotification.tsx @@ -239,10 +239,10 @@ const nextToastId = (): ToastId => { export const notify: INotify = { success: (message, options) => { const id = options?.id ?? nextToastId(); - // Defer one tick: - // Toast fired in the same handler as a router.push is then created AFTER the - // route change — and after the route-change dismiss — so it lands on the - // destination page whether the caller notifies before or after navigating. + // Defer one tick so the toast is created after the route-change + // dismiss above, landing it on the destination page. When a handler + // both navigates and shows a success toast, call notify.success + // before router.push — the reverse order can break auto-dismiss (#48088). setTimeout(() => { toast.custom( (sonnerId) => ( diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index 24d00b0834..5718c5df13 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -575,7 +575,6 @@ initialized. View currently working contexts in the [context directory](../conte ```typescript // Consuming a context — destructure what you need from useContext -const { renderFlash } = useContext(NotificationContext); const { currentUser, isPremiumTier } = useContext(AppContext); ``` @@ -584,7 +583,6 @@ const { currentUser, isPremiumTier } = useContext(AppContext); | Context | Purpose | Use this when | |---|---|---| | `AppContext` | Global app state: current user, config, team selection, role flags, license info | You need user identity, permissions, feature flags, or the active fleet | -| `NotificationContext` | Flash message banners (`renderFlash`, `renderMultiFlash`, `hideFlash`) | You need to show success/error/warning notifications after an action | | `PolicyContext` | In-progress policy editing state: name, query, resolution, platform, labels | You're on the policy edit/create flow and need to persist form state across steps | | `QueryContext` | In-progress report editing state: name, query body, frequency, targets, logging | You're on the report edit/create flow and need to persist form state across steps | | `RoutingContext` | Stores a redirect location for post-auth navigation | You need to redirect the user after login (e.g., deep link they hit while logged out) | @@ -615,7 +613,7 @@ const PageOrComponent = (props) => { // do something } catch(error) { console.error(error); - // maybe trigger renderFlash + // maybe trigger notify.error } }; @@ -695,7 +693,7 @@ try { await softwareAPI.install() // successful messgae } catch (e) { - renderFlash("error", getErrorMessage(e)) + notify.error(getErrorMessage(e)) } /* in helpers.tsx */ @@ -1067,20 +1065,17 @@ then the [app's context](#react-context) should be used. If you are dealing with a page that *updates* any kind of config, set the local config with the response of your update call to make sure it has the latest. -### Rendering flash messages +### Toast notifications -Flash messages by default will be hidden when the user performs any navigation that changes the URL, -in addition to the timeout set for success messages. The `renderFlash` method from notification -context accepts an optional third `options` argument which contains an optional -`persistOnPageChange` boolean field that can be set to `true` to negate this default behavior. +Use `notify.success(msg)` / `notify.error(msg, { response })` / `notify.batch([...])` from +`components/ToastNotification`. Success toasts auto-dismiss after 5s by default; error toasts are sticky by default. +Visible toasts are dismissed automatically on URL change. -If the `renderFlash` is accompanied by a router push, it's important to push to the router *before* -calling `renderFlash`. If the push comes after the `renderFlash` call, -the flash message may register the `push` and immediately hide itself. +**When showing a success toast and navigating, call `notify.success` before `router.push` / `router.replace`** — the reverse order can break auto-dismiss on the destination page (#48088). ```tsx -// first push +// first notify +notify.error("Something went wrong"); +// then push router.push(newPath); -// then flash -renderFlash("error", "Something went wrong"); ``` diff --git a/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx b/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx index 730960b1a5..3d4ff9fc1a 100644 --- a/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx +++ b/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx @@ -56,10 +56,10 @@ const ConfirmInvitePage = ({ router, params }: IConfirmInvitePageProps) => { try { await usersAPI.create(dataForAPI); - router.push(paths.LOGIN); notify.success( "Registration successful! For security purposes, please log in." ); + router.push(paths.LOGIN); } catch (error) { const reason = getErrorReason(error); console.error(reason); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/AppleMdmPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/AppleMdmPage.tsx index dfb5eed366..cd19eaea34 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/AppleMdmPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/AppleMdmPage.tsx @@ -69,8 +69,8 @@ const AppleMdmPage = ({ router }: { router: InjectedRouter }) => { try { await mdmAppleAPI.deleteApplePushCertificate(); await queryClient.invalidateQueries(["config"]); - router.push(PATHS.ADMIN_INTEGRATIONS_MDM); notify.success("MDM turned off successfully."); + router.push(PATHS.ADMIN_INTEGRATIONS_MDM); } catch (e) { notify.error("Couldn't turn off MDM. Please try again.", { response: e, diff --git a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx index bf4ffa8935..7a26125a55 100644 --- a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx +++ b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx @@ -300,8 +300,8 @@ const TeamDetailsWrapper = ({ try { await teamsAPI.destroy(teamIdForApi); + notify.success(`Successfully deleted ${currentTeamName}.`); router.push(PATHS.ADMIN_FLEETS); - notify.success("Fleet removed"); } catch (response) { notify.error("Something went wrong removing the fleet", { response }); console.error(response); @@ -309,7 +309,7 @@ const TeamDetailsWrapper = ({ toggleDeleteFleetModal(); setIsUpdatingTeams(false); } - }, [teamIdForApi, router, toggleDeleteFleetModal]); + }, [teamIdForApi, currentTeamName, router, toggleDeleteFleetModal]); const onEditSubmit = useCallback( async (formData: ITeamFormData) => { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 05fdbc7b40..87a5b7e4b7 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -722,8 +722,8 @@ const HostDetailsPage = ({ setIsUpdating(true); try { await hostAPI.destroy(host); - router.push(PATHS.MANAGE_HOSTS); notify.success(`Host "${host.display_name}" was successfully deleted.`); + router.push(PATHS.MANAGE_HOSTS); } catch (error) { console.log(error); notify.error(`Host "${host.display_name}" could not be deleted.`, { diff --git a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx index 00fd64e7c7..bc29b01d16 100644 --- a/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx +++ b/frontend/pages/labels/NewLabelPage/NewLabelPage.tsx @@ -337,8 +337,8 @@ const NewLabelPage = ({ setIsUpdating(true); try { await labelsAPI.create(formData); - router.push(PATHS.MANAGE_LABELS); notify.success("Label added successfully."); + router.push(PATHS.MANAGE_LABELS); } catch (error) { const status = (error as { status: number }).status; let errorMessage = "Couldn't add label. Please try again."; diff --git a/frontend/pages/packs/EditPackPage/EditPackPage.tsx b/frontend/pages/packs/EditPackPage/EditPackPage.tsx index e27ea96789..4942f2ec7b 100644 --- a/frontend/pages/packs/EditPackPage/EditPackPage.tsx +++ b/frontend/pages/packs/EditPackPage/EditPackPage.tsx @@ -156,8 +156,8 @@ const EditPacksPage = ({ packsAPI .update(packId, updatedPack) .then(() => { - router.push(PATHS.MANAGE_PACKS); notify.success(`Successfully updated this pack.`); + router.push(PATHS.MANAGE_PACKS); }) .catch((e) => { if ( diff --git a/frontend/pages/packs/PackComposerPage/PackComposerPage.tsx b/frontend/pages/packs/PackComposerPage/PackComposerPage.tsx index b19af4d58b..4fabae4d75 100644 --- a/frontend/pages/packs/PackComposerPage/PackComposerPage.tsx +++ b/frontend/pages/packs/PackComposerPage/PackComposerPage.tsx @@ -49,8 +49,8 @@ const PackComposerPage = ({ router }: IPackComposerPageProps): JSX.Element => { const { pack: { id: packID }, } = await create(formData); - router.push(PATHS.PACK(packID)); notify.success("Pack successfully created. Add queries to your pack."); + router.push(PATHS.PACK(packID)); } catch (e) { if ( getErrorReason(e, { diff --git a/frontend/pages/policies/edit/screens/QueryEditor.tsx b/frontend/pages/policies/edit/screens/QueryEditor.tsx index bee5bb28f7..77b204a1a7 100644 --- a/frontend/pages/policies/edit/screens/QueryEditor.tsx +++ b/frontend/pages/policies/edit/screens/QueryEditor.tsx @@ -181,12 +181,12 @@ const QueryEditor = ({ ); } } + notify.success("Policy created."); router.push( getPathWithQueryParams(PATHS.POLICY_DETAILS(policy.id), { fleet_id: policy.team_id, }) ); - notify.success("Policy created."); } catch (createError) { if (getErrorReason(createError).includes("already exists")) { setBackendValidators({ diff --git a/frontend/pages/queries/edit/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage.tsx index 1c15b19c59..e2a447f994 100644 --- a/frontend/pages/queries/edit/EditQueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage.tsx @@ -262,13 +262,13 @@ const EditQueryPage = ({ setIsQuerySaving(true); try { const { query } = await queryAPI.create(formData); + notify.success("Report created."); router.push( getPathWithQueryParams(PATHS.REPORT_DETAILS(query.id), { fleet_id: query.team_id, host_id: hostId, }) ); - notify.success("Report created."); setBackendValidators({}); } catch (createError) { if (getErrorReason(createError).includes("already exists")) {