Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/cpt-ui/__tests__/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,4 +453,66 @@
)
})
})

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(
<MemoryRouter>
<AuthProvider>
<TestComponent />
</AuthProvider>
</MemoryRouter>
)
})

act(() => {
contextValue?.registerBeforeUnloadGuard()
})

const event = new Event("beforeunload", {cancelable: true})
const preventDefaultSpy = jest.spyOn(event, "preventDefault")
window.dispatchEvent(event)

Check warning on line 480 in packages/cpt-ui/__tests__/AuthProvider.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-prescription-tracker-ui&issues=AZ1NYraMY_7EyS8rY1Em&open=AZ1NYraMY_7EyS8rY1Em&pullRequest=1987

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(
<MemoryRouter>
<AuthProvider>
<TestComponent />
</AuthProvider>
</MemoryRouter>
)
})

act(() => {
contextValue?.registerBeforeUnloadGuard()
contextValue?.clearBeforeUnloadGuard()
})

const event = new Event("beforeunload", {cancelable: true})
const preventDefaultSpy = jest.spyOn(event, "preventDefault")
window.dispatchEvent(event)

Check warning on line 514 in packages/cpt-ui/__tests__/AuthProvider.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-prescription-tracker-ui&issues=AZ1NYraMY_7EyS8rY1En&open=AZ1NYraMY_7EyS8rY1En&pullRequest=1987

expect(preventDefaultSpy).not.toHaveBeenCalled()
})
})
3 changes: 2 additions & 1 deletion packages/cpt-ui/__tests__/NavigationProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}))

Expand Down
4 changes: 3 additions & 1 deletion packages/cpt-ui/__tests__/mocks/AuthStateMock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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")
Expand Down
9 changes: 6 additions & 3 deletions packages/cpt-ui/src/context/AccessProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}, [])
Expand Down
25 changes: 24 additions & 1 deletion packages/cpt-ui/src/context/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
createContext,
useContext,
useEffect,
useRef,
useState,
SetStateAction
} from "react"
Expand Down Expand Up @@ -61,12 +62,15 @@
setIsSigningOut: (value: boolean) => void
setStateForSignOut: () => void
setStateForSignIn: () => void
registerBeforeUnloadGuard: () => void
clearBeforeUnloadGuard: () => void
}

export const AuthContext = createContext<AuthContextType | null>(null)

export const AuthProvider = ({children}: { children: React.ReactNode }) => {
Amplify.configure(authConfig, {ssr: false})
const beforeUnloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null)
const [deviceId] = useLocalStorageState<string | undefined>(
"deviceId", "deviceId", crypto.randomUUID())
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -296,6 +300,23 @@
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 = ""

Check warning on line 308 in packages/cpt-ui/src/context/AuthProvider.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'returnValue' is deprecated.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-prescription-tracker-ui&issues=AZ1NQNcJHj5Vd2pK9ZfP&open=AZ1NQNcJHj5Vd2pK9ZfP&pullRequest=1987
}
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) {
Expand Down Expand Up @@ -336,7 +357,9 @@
updateInvalidSessionCause,
setIsSigningOut,
setStateForSignOut,
setStateForSignIn
setStateForSignIn,
registerBeforeUnloadGuard,
clearBeforeUnloadGuard
}}>
{children}
</AuthContext.Provider>
Expand Down
2 changes: 1 addition & 1 deletion packages/cpt-ui/src/context/NavigationProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const NavigationProvider: React.FC<NavigationProviderProps> = ({
!originalSearchParameters ||
originalSearchParameters.searchType !== searchType
) {
logger.info("Navigation: No relevant search parameters found", {
logger.debug("Navigation: No relevant search parameters found", {
searchType,
originalSearchParameters
})
Expand Down
2 changes: 1 addition & 1 deletion packages/cpt-ui/src/helpers/tabHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 8 additions & 3 deletions packages/cpt-ui/src/pages/BasicDetailsSearchResultsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ export default function SearchResultsPage() {
const [patients, setPatients] = useState<Array<PatientSummary>>([])
const searchContext = useSearchContext()
const navigationContext = useNavigationContext()

const [error, setError] = useState(false)

const auth = useAuth()
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<main className="nhsuk-main-wrapper" id="main-content" role="main">
<Container>
Expand Down
9 changes: 9 additions & 0 deletions packages/cpt-ui/src/pages/PrescriptionDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<main
id="main-content"
Expand Down
8 changes: 8 additions & 0 deletions packages/cpt-ui/src/pages/PrescriptionListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ export default function PrescriptionListPage() {
return
}

// Protect against navigating away using browser controls or refreshing
auth.registerBeforeUnloadGuard()

setCurrentPrescriptions(searchResults.currentPrescriptions)
setFuturePrescriptions(searchResults.futurePrescriptions)
setPastPrescriptions(searchResults.pastPrescriptions)
Expand Down Expand Up @@ -174,9 +177,14 @@ export default function PrescriptionListPage() {
}

runSearch()
return () => {
auth.clearBeforeUnloadGuard()
}
}, [])

if (loading) {
// Protect against navigating away using browser controls or refreshing while loading
auth.registerBeforeUnloadGuard()
return (
<main id="main-content" className="nhsuk-main-wrapper">
<Container>
Expand Down
Loading