diff --git a/packages/cpt-ui/__tests__/AuthProvider.test.tsx b/packages/cpt-ui/__tests__/AuthProvider.test.tsx
index 88db4b9651..c6144f9093 100644
--- a/packages/cpt-ui/__tests__/AuthProvider.test.tsx
+++ b/packages/cpt-ui/__tests__/AuthProvider.test.tsx
@@ -453,4 +453,66 @@ describe("AuthProvider", () => {
)
})
})
+
+ it("should call preventDefault on beforeunload after registerBeforeUnloadGuard is called", async () => {
+ let contextValue: AuthContextType | null = null
+ const TestComponent = () => {
+ contextValue = useContext(AuthContext)
+ return null
+ }
+
+ await act(async () => {
+ render(
+
+
+
+
+
+ )
+ })
+
+ act(() => {
+ contextValue?.registerBeforeUnloadGuard()
+ })
+
+ const event = new Event("beforeunload", {cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault")
+ window.dispatchEvent(event)
+
+ expect(preventDefaultSpy).toHaveBeenCalled()
+
+ // Cleanup so the guard doesn't leak into other tests
+ act(() => {
+ contextValue?.clearBeforeUnloadGuard()
+ })
+ })
+
+ it("should not call preventDefault on beforeunload after clearBeforeUnloadGuard is called", async () => {
+ let contextValue: AuthContextType | null = null
+ const TestComponent = () => {
+ contextValue = useContext(AuthContext)
+ return null
+ }
+
+ await act(async () => {
+ render(
+
+
+
+
+
+ )
+ })
+
+ act(() => {
+ contextValue?.registerBeforeUnloadGuard()
+ contextValue?.clearBeforeUnloadGuard()
+ })
+
+ const event = new Event("beforeunload", {cancelable: true})
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault")
+ window.dispatchEvent(event)
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
+ })
})
diff --git a/packages/cpt-ui/__tests__/NavigationProvider.test.tsx b/packages/cpt-ui/__tests__/NavigationProvider.test.tsx
index 348818ebe0..87e7643aeb 100644
--- a/packages/cpt-ui/__tests__/NavigationProvider.test.tsx
+++ b/packages/cpt-ui/__tests__/NavigationProvider.test.tsx
@@ -8,7 +8,8 @@ import {logger} from "@/helpers/logger"
jest.mock("@/helpers/logger", () => ({
logger: {
info: jest.fn(),
- warn: jest.fn()
+ warn: jest.fn(),
+ debug: jest.fn()
}
}))
diff --git a/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx b/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
index d027788e38..a35ce03071 100644
--- a/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
+++ b/packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
@@ -45,7 +45,9 @@ export const mockAuthState = {
setStateForSignIn: jest.fn().mockImplementation(() => Promise.resolve()),
setSessionTimeoutModalInfo: jest.fn(),
setLogoutModalType: jest.fn(),
- remainingSessionTime: undefined
+ remainingSessionTime: undefined,
+ registerBeforeUnloadGuard: jest.fn(),
+ clearBeforeUnloadGuard: jest.fn()
} as unknown as AuthContextType
/**
diff --git a/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx b/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx
index ae70f837d6..a66ac441c0 100644
--- a/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx
+++ b/packages/cpt-ui/src/components/prescriptionSearch/BasicDetailsSearch.tsx
@@ -24,6 +24,7 @@ import {FRONTEND_PATHS} from "@/constants/environment"
import {useSearchContext} from "@/context/SearchProvider"
import {useNavigationContext} from "@/context/NavigationProvider"
import {usePageTitle} from "@/hooks/usePageTitle"
+import {useAuth} from "@/context/AuthProvider"
export default function BasicDetailsSearch() {
const navigate = useNavigate()
@@ -41,6 +42,7 @@ export default function BasicDetailsSearch() {
const inlineErrors = getInlineErrors(errors)
const searchContext = useSearchContext()
const navigationContext = useNavigationContext()
+ const auth = useAuth()
usePageTitle(errors.length > 0
? STRINGS.PAGE_TITLE_ERROR
@@ -58,6 +60,13 @@ export default function BasicDetailsSearch() {
}
}, [errors])
+ useEffect(() => {
+ // Clear the before-unload guard when leaving/unmounting this page
+ return () => {
+ auth.clearBeforeUnloadGuard()
+ }
+ }, [])
+
// restore original search parameters when available
useEffect(() => {
const relevantParams = navigationContext.getRelevantSearchParameters("basicDetails")
diff --git a/packages/cpt-ui/src/context/AccessProvider.tsx b/packages/cpt-ui/src/context/AccessProvider.tsx
index c4c90af803..bdc56275cc 100644
--- a/packages/cpt-ui/src/context/AccessProvider.tsx
+++ b/packages/cpt-ui/src/context/AccessProvider.tsx
@@ -36,13 +36,16 @@ export const AccessProvider = ({children}: {children: ReactNode}) => {
updateOpenTabs(tabId, "add")
- const onBeforeUnload = () => {
+ const onPageHide = () => {
updateOpenTabs(tabId, "remove")
}
- window.addEventListener("beforeunload", onBeforeUnload)
+ // pagehide fires only on actual unload (confirmed navigation, tab close).
+ // beforeunload fires even when the user cancels the prompt and stays on the page,
+ // which would incorrectly remove the tab ID from the open-tabs list.
+ window.addEventListener("pagehide", onPageHide)
return () => {
- window.removeEventListener("beforeunload", onBeforeUnload)
+ window.removeEventListener("pagehide", onPageHide)
updateOpenTabs(tabId, "remove")
}
}, [])
diff --git a/packages/cpt-ui/src/context/AuthProvider.tsx b/packages/cpt-ui/src/context/AuthProvider.tsx
index 3c1cef4e98..eda5204f4d 100644
--- a/packages/cpt-ui/src/context/AuthProvider.tsx
+++ b/packages/cpt-ui/src/context/AuthProvider.tsx
@@ -2,6 +2,7 @@ import React, {
createContext,
useContext,
useEffect,
+ useRef,
useState,
SetStateAction
} from "react"
@@ -61,12 +62,15 @@ export interface AuthContextType {
setIsSigningOut: (value: boolean) => void
setStateForSignOut: () => void
setStateForSignIn: () => void
+ registerBeforeUnloadGuard: () => void
+ clearBeforeUnloadGuard: () => void
}
export const AuthContext = createContext(null)
export const AuthProvider = ({children}: { children: React.ReactNode }) => {
Amplify.configure(authConfig, {ssr: false})
+ const beforeUnloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null)
const [deviceId] = useLocalStorageState(
"deviceId", "deviceId", crypto.randomUUID())
const [error, setError] = useState(null)
@@ -296,6 +300,23 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
return rolesWithAccess.length === 1 && rolesWithoutAccess.length === 0
}
+ const registerBeforeUnloadGuard = () => {
+ if (beforeUnloadHandlerRef.current) return // already registered
+ beforeUnloadHandlerRef.current = (e: BeforeUnloadEvent) => {
+ // Ensure navigate-away protection works consistently across browsers
+ e.preventDefault()
+ e.returnValue = ""
+ }
+ window.addEventListener("beforeunload", beforeUnloadHandlerRef.current)
+ }
+
+ const clearBeforeUnloadGuard = () => {
+ if (beforeUnloadHandlerRef.current) {
+ window.removeEventListener("beforeunload", beforeUnloadHandlerRef.current)
+ beforeUnloadHandlerRef.current = null
+ }
+ }
+
// Wrap setLogoutModalType to match the expected signature
const setLogoutModalTypeAsync = async (value: "userInitiated" | "timeout" | undefined) => {
if (logoutModalType === undefined || value === undefined) {
@@ -336,7 +357,9 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => {
updateInvalidSessionCause,
setIsSigningOut,
setStateForSignOut,
- setStateForSignIn
+ setStateForSignIn,
+ registerBeforeUnloadGuard,
+ clearBeforeUnloadGuard
}}>
{children}
diff --git a/packages/cpt-ui/src/context/NavigationProvider.tsx b/packages/cpt-ui/src/context/NavigationProvider.tsx
index 41c1b1962b..c453bde530 100644
--- a/packages/cpt-ui/src/context/NavigationProvider.tsx
+++ b/packages/cpt-ui/src/context/NavigationProvider.tsx
@@ -160,7 +160,7 @@ export const NavigationProvider: React.FC = ({
!originalSearchParameters ||
originalSearchParameters.searchType !== searchType
) {
- logger.info("Navigation: No relevant search parameters found", {
+ logger.debug("Navigation: No relevant search parameters found", {
searchType,
originalSearchParameters
})
diff --git a/packages/cpt-ui/src/helpers/tabHelpers.ts b/packages/cpt-ui/src/helpers/tabHelpers.ts
index 010d125191..a2944d85d3 100644
--- a/packages/cpt-ui/src/helpers/tabHelpers.ts
+++ b/packages/cpt-ui/src/helpers/tabHelpers.ts
@@ -20,7 +20,7 @@ export const getOrCreateTabId = () => {
const existingTabId = window.sessionStorage.getItem(TAB_ID_SESSION_KEY)
if (existingTabId) {
// Check if another tab is already using this ID (i.e. this tab was duplicated).
- // On a normal reload, beforeunload removes the ID from the open tabs list first,
+ // On a normal reload, pagehide removes the ID from the open tabs list first,
// so it won't appear here. On a duplicate, the original tab is still open and
// its ID remains in the list, so we detect the collision and create a new one.
const openTabIds = getOpenTabIds()
diff --git a/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx b/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx
index aa78a14856..1da5f29a09 100644
--- a/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx
+++ b/packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx
@@ -112,7 +112,6 @@ export default function SearchResultsPage() {
const [patients, setPatients] = useState>([])
const searchContext = useSearchContext()
const navigationContext = useNavigationContext()
-
const [error, setError] = useState(false)
const auth = useAuth()
@@ -124,15 +123,19 @@ export default function SearchResultsPage() {
usePageTitle(pageTitle)
useEffect(() => {
+ auth.registerBeforeUnloadGuard()
getSearchResults()
+ return () => {
+ auth.clearBeforeUnloadGuard()
+ }
}, [])
const getSearchResults = async () => {
try {
// Catch empty search parameters, caused by loading the page without coming from the search component
// ie. Hard refresh on the page
- if (searchContext.searchType !== "basicDetails" && !searchContext.lastName &&
- !searchContext.dobDay && !searchContext.dobMonth && !searchContext.dobYear) {
+ if (searchContext.searchType !== "basicDetails" || !searchContext.lastName ||
+ !searchContext.dobDay || !searchContext.dobMonth || !searchContext.dobYear) {
logger.info("Missing basic details search parameters - redirecting to basic details search")
navigate(FRONTEND_PATHS.SEARCH_BY_BASIC_DETAILS)
return
@@ -192,6 +195,8 @@ export default function SearchResultsPage() {
.toSorted((a, b) => (a.givenName?.[0] ?? "").localeCompare(b.givenName?.[0] ?? ""))
if (loading) {
+ // Protect against navigating away using browser controls or refreshing while loading
+ auth.registerBeforeUnloadGuard()
return (
diff --git a/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx b/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx
index 093a19e709..d7ce1e1736 100644
--- a/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx
+++ b/packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx
@@ -85,6 +85,9 @@ export default function PrescriptionDetailsPage() {
return
}
+ // Protect against navigating away using browser controls or refreshing
+ auth.registerBeforeUnloadGuard()
+
// Use the populated payload
setPrescriptionInformation(payload)
setItems(payload.items)
@@ -120,9 +123,15 @@ export default function PrescriptionDetailsPage() {
}
runGetPrescriptionDetails()
+
+ return () => {
+ auth.clearBeforeUnloadGuard()
+ }
}, [])
if (loading) {
+ // Protect against navigating away using browser controls or refreshing while loading
+ auth.registerBeforeUnloadGuard()
return (
{
+ auth.clearBeforeUnloadGuard()
+ }
}, [])
if (loading) {
+ // Protect against navigating away using browser controls or refreshing while loading
+ auth.registerBeforeUnloadGuard()
return (