Enhancement - Warn users when navigating away from cohort builder with unsaved changes#2764
Open
khairul-syazwan wants to merge 19 commits into
Open
Enhancement - Warn users when navigating away from cohort builder with unsaved changes#2764khairul-syazwan wants to merge 19 commits into
khairul-syazwan wants to merge 19 commits into
Conversation
- Add useUnsavedChanges composable exposing isDirty, guard(action), install/uninstall, confirmLeave/cancelLeave. Listens for single-spa:before-routing-event and beforeunload. - Refactor App.vue to Composition API (script setup) and mount the UnsavedChangesDialog alongside install/uninstall of listeners. - Bookmarks.vue: replace local messageBox save/discard with unsavedChanges.guard() at loadBookmarkCheck and openAddNewCohort. - portalPropsListener: add guardChange option that fires only on dataset/release deltas; wire it through lifecycles.ts so the cohort builder can intercept dataset switches. - vitest: inline vuetify and polyfill visualViewport/matchMedia so Vuetify components can mount in happy-dom for tests.
…owing Remove block sizing from action buttons and switch card width to max-width so the dialog stays within the 540px Figma spec without clipping.
… navigation correctly - Use event.detail.cancelNavigation(true) instead of preventDefault() for single-spa routing events. - Make install/uninstall idempotent to avoid duplicate listeners. - Prevent concurrent guards while the dialog is already open. - Track the expected navigation URL so confirming leave does not reopen the dialog. - Suppress dirty state while a bookmark is being restored. - Expand unit tests for cancelNavigation, idempotency, and concurrent guard behavior.
…ctive bookmark - Add isRestoringBookmark flag with SET_IS_RESTORING_BOOKMARK mutation. - Set the flag around loadbookmarkToState and loadBookmarkDataToState. - Normalize SET_ACTIVE_BOOKMARK so isNew is always a boolean. - Update bookmark store tests for normalized active bookmark.
…nges check - Wrap loadAtlasBookmark and openNewAtlasBookmark with unsavedChanges.guard. - Accept an optional guard callback in useDeepLink and wire it from App.vue. - Reset active bookmark when the dataset/release changes so dirty state does not persist. - Update datasetWatcher and datasetPropagation tests for the new commit order.
- Add activeBookmarkBaseline state and SET_ACTIVE_BOOKMARK_BASELINE mutation. - Compare current bookmark data against baseline in getCurrentBookmarkHasChanges. - Reset baseline whenever active bookmark changes. - Capture baseline after loading an existing bookmark or creating a new one. - Make resetChart return a promise so addNewCohort can await the reset. - Remove activeBookmark.isNew from the unsaved-changes dirty check. - Update bookmark store tests for baseline behavior.
…ing on navigation - Add @mdi/font dependency and import its CSS in the vuetify plugin. - Attach UnsavedChangesDialog to #app so it is removed with the microfrontend. - Reset dialog state in App.vue onBeforeUnmount. - Call cancelNavigation() without arguments per single-spa docs. - Update tests for the close icon and navigation cancellation.
…alog colors - Only use activeBookmarkBaseline for bookmarks without saved JSON. - Ignore single-spa routing events while the warning dialog is already open. - Override Bootstrap's .text-primary inside PA to use the theme primary color. - Define --color-primary in the d2e theme. - Update tests for saved-bookmark dirty state and routing-event guard.
…kmark dirty normalization - Add window.__d2eUnsavedChangesRegistry and register MRI app from useUnsavedChanges - Add DirtyStateAwareRouter + MUI UnsavedChangesDialog to portal - Normalize barChartType.showDistributionOverlay in bookmark dirty check - Add unit tests for registry, router, and bookmark comparison
…t, dialog polish - normalize auto-defaulted colorAxis on BOTH sides of the dirty comparison so it is timing-invariant; add chart isColorAxisAutoDefaulted flag + setDefaultColorAxisIndex - clear active bookmark on unmount (Vuex module state is shared across single-spa remounts) - reset to default view (full-width cohorts, no chart, default filters) when a dataset/release switch completes - keep the splitpanes pane static (remove width slide transition) - restore bookmark on ?bmkId remount (loadBookmark was ignoring its args) - register clearUnsavedChanges with the cross-app registry (adds clearAll) - style UnsavedChangesDialog to the Figma spec (explicit #80, 18px title) - tests for the colorAxis normalization, chart flag, registry, and composable
- rename DirtyStateAwareRouter -> NavigationGuardRouter - remove the unreliable single-spa back-button (popstate) handler; rely on beforeunload + in-app link guard (back-button is a documented limitation) - add clearAll() to the registry contract and call it on Leave - guard dataset/release dropdown switches with a new useGuardedChange hook so the selection reverts to the current value on Stay - style UnsavedChangesDialog to the Figma spec (explicit #80, 18px title) - tests for the router, dialog, and useGuardedChange
Documents the window.__d2eUnsavedChangesRegistry contract so other single-spa apps can register hasUnsavedChanges/clearUnsavedChanges and integrate with the portal guard + beforeunload.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces a cross-micro-frontend unsaved-changes guard spanning the portal shell and the Patient Analytics (vue-mri-ui-lib) app, aiming to prevent silent loss of cohort builder work by warning users before navigations that would discard uncommitted changes.
Changes:
- Adds a shared
window.__d2eUnsavedChangesRegistryand PA wiring (useUnsavedChanges,beforeunload, baseline handling) to report/clear dirty state across apps. - Replaces the portal
BrowserRouterwith a history-wrappingNavigationGuardRouterto synchronously interceptpush/replaceand show a shared dialog. - Adds guarded non-router changes in the portal (dataset/release switching) plus supporting UI/tests/docs and PA store adjustments to avoid false-positive dirty states.
Reviewed changes
Copilot reviewed 42 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/ui/docs/cross-app-unsaved-changes.md | New integration guide for registering apps with the cross-app unsaved-changes registry. |
| plugins/ui/bun.lock | Lockfile updates for new UI dependencies (e.g., MDI font, sass). |
| plugins/ui/apps/vue-mri-ui-lib/vitest.setup.ts | Test-environment polyfills for Vuetify (visualViewport, matchMedia). |
| plugins/ui/apps/vue-mri-ui-lib/vite.config.ts | Vitest config tweaks (inline Vuetify deps for test server). |
| plugins/ui/apps/vue-mri-ui-lib/src/styles/themes/_main.scss | Defines primary color CSS variable used by dialogs/theme-aware components. |
| plugins/ui/apps/vue-mri-ui-lib/src/styles/style.scss | Overrides Bootstrap .text-primary to respect app theme primary color. |
| plugins/ui/apps/vue-mri-ui-lib/src/store/mutation-types.ts | Adds mutation constants for bookmark restore/baseline and color-axis default tracking. |
| plugins/ui/apps/vue-mri-ui-lib/src/store/modules/chart.ts | Tracks auto-defaulted color axis and fixes reset sequencing. |
| plugins/ui/apps/vue-mri-ui-lib/src/store/modules/bookmark.ts | Adds restore/baseline logic to stabilize dirty-state detection and prevent false positives. |
| plugins/ui/apps/vue-mri-ui-lib/src/store/modules/tests/chart.colorAxis.test.ts | Unit tests for color-axis auto-default tracking. |
| plugins/ui/apps/vue-mri-ui-lib/src/store/modules/tests/bookmark.test.ts | Expanded unit tests for baseline-based dirty detection and normalization rules. |
| plugins/ui/apps/vue-mri-ui-lib/src/shared/unsavedChangesRegistry.ts | New cross-app registry singleton exposed on window.__d2eUnsavedChangesRegistry. |
| plugins/ui/apps/vue-mri-ui-lib/src/shared/tests/unsavedChangesRegistry.test.ts | Unit tests for registry behavior (dirty checks, unregister, clearAll). |
| plugins/ui/apps/vue-mri-ui-lib/src/plugins/vuetify.ts | Adds MDI icon font import required by the new dialog (close icon). |
| plugins/ui/apps/vue-mri-ui-lib/src/lifecycles.ts | Wires portal dataset/release changes through PA unsaved-changes guard. |
| plugins/ui/apps/vue-mri-ui-lib/src/lib/i18n.ts | Updates unsaved-changes strings and adds “Stay/Leave without saving” labels. |
| plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts | New composable for PA dirty detection, registry integration, and guarded actions. |
| plugins/ui/apps/vue-mri-ui-lib/src/composables/useDeepLink.ts | Adds optional guard hook when deep-link loading would overwrite dirty state. |
| plugins/ui/apps/vue-mri-ui-lib/src/composables/tests/useUnsavedChanges.test.ts | Unit tests for the PA unsaved-changes composable behavior. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/UnsavedChangesDialog.vue | New PA dialog component for guarded in-app actions. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/ShinyViewer/SaveCohortModal.vue | Captures baseline after save to clear dirty state post-save. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/PatientAnalytics.vue | Resets view on dataset switch completion; disables splitpanes width transition. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/FiltersFooter.vue | Captures baseline after save/reload flow to avoid lingering dirty state. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/ChartController.vue | Uses auto-default setter when app chooses a default color axis. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/Bookmarks.vue | Replaces legacy save/discard prompt with shared guard; captures baselines for Atlas/new flows. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/tests/UnsavedChangesDialog.test.ts | Unit tests for the new PA unsaved-changes dialog. |
| plugins/ui/apps/vue-mri-ui-lib/src/components/tests/App.startup.test.ts | Updates startup test selectors; stubs UnsavedChangesDialog. |
| plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/portalPropsListener.ts | Adds guardChange hook for dataset/release changes coming from the portal. |
| plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/datasetWatcher.ts | Clears active bookmark when dataset reload begins (prevents stale dirty state). |
| plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/tests/portalPropsListener.test.ts | Tests for guarding dataset/release changes via guardChange. |
| plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/tests/datasetWatcher.test.ts | Updates expectations for bookmark-clearing on dataset reload. |
| plugins/ui/apps/vue-mri-ui-lib/src/bootstrap/tests/datasetPropagation.test.ts | Updates expectations for bookmark-clearing in dataset propagation path. |
| plugins/ui/apps/vue-mri-ui-lib/src/App.vue | Installs/uninstalls unsaved-changes wiring and renders the shared PA dialog. |
| plugins/ui/apps/vue-mri-ui-lib/package.json | Adds @mdi/font dependency and reorders dependency list. |
| plugins/ui/apps/portal/src/index.tsx | Wraps the portal app with NavigationGuardRouter instead of BrowserRouter. |
| plugins/ui/apps/portal/src/hooks/useGuardedChange.ts | New hook to guard dataset/release switches against cross-app dirty state. |
| plugins/ui/apps/portal/src/hooks/tests/useGuardedChange.test.tsx | Tests for guarded change behavior and dialog flow. |
| plugins/ui/apps/portal/src/components/UnsavedChangesDialog/UnsavedChangesDialog.tsx | New portal-level shared unsaved-changes dialog (MUI). |
| plugins/ui/apps/portal/src/components/UnsavedChangesDialog/tests/UnsavedChangesDialog.test.tsx | Tests for portal dialog rendering and callbacks. |
| plugins/ui/apps/portal/src/components/NavigationGuardRouter/NavigationGuardRouter.tsx | New router wrapper intercepting push/replace and coordinating clearAll() before leaving. |
| plugins/ui/apps/portal/src/components/NavigationGuardRouter/tests/NavigationGuardRouter.test.tsx | Tests for blocking/resuming navigation via the portal router guard. |
| plugins/ui/apps/portal/src/components/Header/SelectRelease/SelectRelease.tsx | Guards release switching against unsaved changes and shows shared dialog. |
| plugins/ui/apps/portal/src/components/Header/SelectDataset/SelectDataset.tsx | Guards dataset switching against unsaved changes and shows shared dialog. |
hengxian-jiang
approved these changes
Jun 24, 2026
…omise and deep-link error handling - Bookmarks.vue reset(): return the resetChart() promise so addNewCohort's `await this.reset()` actually waits for setIFRState + setupChartDefaults to settle before snapshotting the baseline — fixes false dirty state when create-new-cohort → load saved bookmark → create-new-cohort → load saved bookmark - addNewCohort: await $nextTick() after reset before capturing baseline so reactive chart defaults (colorAxis auto-default) have flushed - confirmRenameBookmark: preserve and restore activeBookmarkBaseline around the metadata-only SET_ACTIVE_BOOKMARK commit — prevents rename from dropping the baseline and falling back to the fragile legacy raw-JSON dirty comparison - QueryFilterModern saveAtlasCohort: recapture baseline after SET_ACTIVE_BOOKMARK on both create and update paths — persisted state is the new clean reference - useDeepLink: wrap load() body in its own try/catch so a failed restore surfaces the error toast when the guard invokes load() detached (the guard does not await it, so the outer try/catch would never see the rejection) - useUnsavedChanges: use types.SET_ACTIVE_BOOKMARK_BASELINE constant instead of the raw string literal in clearUnsavedChanges
…ways dirty A cohort loaded from a deep link or Atlas import has never been saved as a PA bookmark. It should always report dirty so the user is prompted before navigating away, until they explicitly save it. - loadBookmarkDataToState: remove the post-restore baseline capture so the deep-link bookmark has no baseline - getCurrentBookmarkHasChanges: change the no-baseline + no-.bookmark branch to return true instead of false — no saved JSON and no baseline means unsaved external work, not a blank new cohort (addNewCohort captures its own baseline immediately after reset, keeping blank cohorts clean)
… khairul-syazwan/analyze-2636
jerome-ng
approved these changes
Jun 24, 2026
suwarnoong
approved these changes
Jul 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #2636
What changed
This PR adds a portal-wide unsaved-changes guard that protects the user from leaving a plugin while they have uncommitted work. The guard is implemented in three layers:
beforeunloadlistener per micro-frontendwindow.__d2eUnsavedChangesRegistryNavigationGuardRouterintercepts React Routerpush/replacePortal shell
NavigationGuardRouterwraps the app inindex.tsxand replaces the previousBrowserRouter.@remix-run/routerdirectly to create and wrap the history object, so we can intercepthistory.push/history.replacesynchronously.UnsavedChangesDialogis shown.clearAll()so the dirty app resets its baseline and does not re-block the navigation that follows.useGuardedChangehook guards non-router state changes such as dataset and release switches in the portal header.Patient Analytics (vue-mri-ui-lib)
unsavedChangesRegistrymodule creates the cross-app registry singleton onwindow.__d2eUnsavedChangesRegistry.useUnsavedChangescomposable registers/unregisters PA and exposes aguard()helper for in-app flows (dataset switch, deep-link load, Atlas load).UnsavedChangesDialog.vuefor PA-internal guarded actions.SET_ACTIVE_BOOKMARK_BASELINE) and clears the active bookmark on unmount to prevent stale dirty state when the user navigates back.useDeepLinkandBookmarks.vueupdated to use the new guard.Quality
NavigationGuardRouter,UnsavedChangesDialog,useGuardedChange,useUnsavedChanges, and the registry.plugins/ui/docs/cross-app-unsaved-changes.md.Architecture
flowchart TD subgraph "Portal shell" A[Link / menu click] --> B[NavigationGuardRouter] B --> C{Has unsaved changes?} C -->|No| D[Call original history.push/replace] C -->|Yes| E[Show UnsavedChangesDialog] E -->|Stay| F[Cancel pending navigation] E -->|Leave| G[clearAll then replay original push/replace] end subgraph "Cross-app registry" R[window.__d2eUnsavedChangesRegistry] R --> PA[PA: useUnsavedChanges] R --> MF[Future micro-frontends] end B -.->|queries| R G -.->|resets dirty apps| RIn-app navigation sequence
sequenceDiagram actor User participant UI as Portal nav link participant NG as NavigationGuardRouter participant REG as __d2eUnsavedChangesRegistry participant PA as PA / vue-mri-ui-lib participant D as UnsavedChangesDialog User->>UI: Click link to another plugin UI->>NG: history.push(target) NG->>REG: hasAnyUnsavedChanges() REG->>PA: hasUnsavedChanges() PA-->>REG: true REG-->>NG: true NG->>D: open = true D-->>User: Stay / Leave alt User chooses Stay D->>NG: onCancel NG->>NG: drop pending navigation else User chooses Leave D->>NG: onLeave NG->>REG: clearAll() REG->>PA: clearUnsavedChanges() PA->>PA: re-baseline bookmark NG->>NG: replay original history.push(target) endWhy
@remix-run/routeris used for the portal routerThe portal previously used React Router's declarative
BrowserRouter. Declarative routers do not expose a synchronous interception point for allpush/replacecalls. To pause navigation, show a dialog, and optionally cancel or resume, we need access to the underlyinghistoryobject.@remix-run/routeris the low-level routing package that React Router itself is built on. By creating the history withcreateBrowserHistory({ v5Compat: true }), wrapping itspushandreplacemethods, and then passing that wrapped history into React Router'sunstable_HistoryRouter, we get:Routes,Link,useNavigate, etc.).createBrowserRouter) required.Why not just upgrade to the latest React Router?
Modern React Router v6.20+ promotes the data router pattern (
createBrowserRouter+RouterProvider). That would give ususeBlockerfor blocking navigation, but it would also require a much larger change:<Routes><Route .../></Routes>tree throughout the app. Moving to a data router means lifting all route definitions into a singlecreateBrowserRouterconfig, converting data-loading patterns toloader/actionAPIs, and re-testing the entire shell.useBlockerhas a reported issue where pressing Back changes the location before the blocker can stop it (react-router #11589). Upgrading would not solve the Back/Forward limitation in this PR.push/replace. Using@remix-run/routerdirectly gives us exactly that with a small, scoped change while keeping the rest of the declarative router code unchanged.flowchart LR subgraph "Low-level history wrapping" H0["@remix-run/router createBrowserHistory"] H0 --> H1["Wrap push/replace"] H1 --> H2["Inject into unstable_HistoryRouter"] end subgraph "Declarative React Router still works" H2 --> L["Link"] H2 --> N["useNavigate"] H2 --> R["Routes / Route"] end L -.->|calls| H1 N -.->|calls| H1Known limitation: browser Back/Forward button
The guard does not intercept the browser Back/Forward buttons. This is a documented limitation of the current stack, not a wiring bug. Full details are in
UNSAVED_CHANGES_BACK_BUTTON_LIMITATION.md.Why it cannot be reliably blocked
When the user presses Back/Forward, the browser fires
popstateafter it has already moved the history pointer and changed the URL.popstateis not cancelable withpreventDefault. By the time our code runs, the navigation has effectively happened.In the portal, two independent systems react to that
popstate:activeWhenand mounts/unmounts apps.Intercepting at either layer has proven unreliable:
useBlockercreateBrowserRouter+RouterProvider). The portal uses declarativeunstable_HistoryRouter. Even with migration, back-button blocking is buggy (react-router #11589).cancelNavigation()sequenceDiagram actor User participant Browser participant SPA as single-spa participant RR as React Router User->>Browser: Press Back button Browser->>Browser: Update URL & move history pointer Browser->>SPA: popstate event Browser->>RR: popstate event SPA->>SPA: Re-evaluate activeWhen, mount/unmount apps RR->>RR: Update location, re-render shell Note over SPA,RR: Both systems react independently.<br/>No synchronous way to cancel the navigation.<br/>preventDefault() has no effect on popstate.What is still protected
NavigationGuardRouterdialog ✅useGuardedChangedialog ✅useUnsavedChanges.guard()dialog ✅beforeunloadprompt ✅Decision
We intentionally do not intercept Back/Forward. The half-working single-spa handler was net-negative UX (the page navigated back and then the dialog appeared), and migrating the portal to a React Router data router is high-effort and still would not reliably block the Back button.
Destructive exits — reload, tab close, and external navigation — are covered by the native
beforeunloadprompt. If Back/Forward blocking becomes a hard requirement in the future, the established option is a history sentinel trap, which would be implemented as a small, self-contained portal-level module.How other single-spa apps can participate
Any micro-frontend in the portal can opt into the guard by registering with
window.__d2eUnsavedChangesRegistry. The registry is framework-agnostic: the first app that loads creates the singleton, and every later app reuses it.Minimal integration
How the dirty-state watcher is shared
flowchart TD subgraph "Single-spa runtime" S[single-spa orchestrator] S --> PA[PA mounts] S --> MF[Other app mounts] end subgraph "Shared registry on window" R[__d2eUnsavedChangesRegistry] R --> PA_API[PA registers hasUnsavedChanges + clearUnsavedChanges] R --> MF_API[Other app registers hasUnsavedChanges + clearUnsavedChanges] end subgraph "Portal guard" G[NavigationGuardRouter] G -->|hasAnyUnsavedChanges| R end PA -->|install| PA_API MF -->|install| MF_APIContract requirements
hasUnsavedChanges()must be synchronous and cheap — it is called on every guarded navigation.clearUnsavedChanges()should make the app report not dirty so the subsequent navigation is not re-blocked.unregisteron unmount. single-spa preserves module state, so a stale registration would make the portal think an unmounted app is still dirty.beforeunloadlistener for full page-exit protection.Reference implementations
plugins/ui/apps/vue-mri-ui-lib/src/shared/unsavedChangesRegistry.ts— registry singleton.plugins/ui/apps/vue-mri-ui-lib/src/composables/useUnsavedChanges.ts— mount/unmount/guard wiring.plugins/ui/docs/cross-app-unsaved-changes.md— full integration guide.Test coverage
plugins/ui/apps/portal/src/components/NavigationGuardRouter/__tests__/NavigationGuardRouter.test.tsxplugins/ui/apps/portal/src/components/UnsavedChangesDialog/__tests__/UnsavedChangesDialog.test.tsxplugins/ui/apps/portal/src/hooks/__tests__/useGuardedChange.test.tsxplugins/ui/apps/vue-mri-ui-lib/src/composables/__tests__/useUnsavedChanges.test.tsplugins/ui/apps/vue-mri-ui-lib/src/components/__tests__/UnsavedChangesDialog.test.tsplugins/ui/apps/vue-mri-ui-lib/src/shared/__tests__/unsavedChangesRegistry.test.tsplugins/ui/apps/vue-mri-ui-lib/src/store/modules/__tests__/bookmark.test.tsDocs
plugins/ui/docs/cross-app-unsaved-changes.md— integration guide for future micro-frontends.UNSAVED_CHANGES_BACK_BUTTON_LIMITATION.md— detailed technical limitation note.Merge Checklist
Please cross check this list if additions / modifications needs to be done on top of your core changes and tick them off. Reviewer can as well glance through and help the developer if something is missed out.
developbranch)