Repository navigation
Description
The gateway lets a patient reload, close or navigate away from an instrument they are part-way through without any warning, and every unsaved answer is lost. InstrumentRenderer takes an injected NavigationBlocker, which InstrumentRendererContainer keeps active for the whole content step. apps/web passes one that, among other things, registers beforeunload (enableBeforeUnload: () => active) with the message "Your responses to this instrument have not been saved and will be lost". The gateway passes nothing, and nothing else in the app listens for beforeunload. Patients on phones are the ones most likely to hit this: a swipe-back gesture, a pull-to-refresh, or a stray tap on the browser's back button wipes a long form with no prompt. Answers to a scalar instrument are held only in React state, so the patient has to reload, solve the verification again and start over. A series loses the item in progress.
Where
apps/gateway/src/Root.tsx:82-91. No NavigationBlocker is passed:
<InstrumentRenderer
disableSummaryActions
beforeBegin={<CapWidget onSolve={(token) => void handleSolve(token)} />}
className="min-h-full w-full"
disableBegin={!verified}
initialSeriesIndex={initialSeriesIndex}
target={target}
onSubmit={handleSubmit}
/>Compare apps/web/src/components/NavigationBlocker.tsx:7-12:
const blocker = useBlocker({
enableBeforeUnload: () => active,
shouldBlockFn: () => active,
withResolver: true
});Reproduce
- Open an assignment link, verify, click Begin and answer most of a form.
- Press reload, close the tab, or swipe back on a phone.
Actual: the page unloads at once and the answers are gone. Reopening the link starts the instrument from the overview, behind a new verification.
Expected: the browser shows its "Leave site? Changes you made may not be saved" confirmation while the content step is active. Once the instrument is submitted (summary step), it no longer asks.
Tests
apps/gateway/src/components/__tests__/NavigationBlocker.test.tsx(happy-dom):it('should cancel beforeunload while an instrument is in progress, so a stray reload does not discard answers')andit('should let the page unload freely once the blocker is inactive').- e2e,
testing/src/specs/gateway-assignment.spec.ts: after Begin, callgatewayPage.close({ runBeforeUnload: true })and assert that abeforeunloaddialog event fires. After submitting, assert that none does.
Suggested fix
Add a small NavigationBlocker component to apps/gateway/src/components/ that implements NavigationBlockerProps. While active is true, it adds a beforeunload listener that calls event.preventDefault() (the gateway has no client-side routes, so NavigationBlockerDialog is not needed). Pass it to InstrumentRenderer in Root.tsx.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing underapps/gateway/srcorpackages/react-core/src/components/InstrumentRendererhas changed since this was filed.Notes on the suggested fix
event.returnValueas well as callingpreventDefault(). Chrome and Android WebView honourpreventDefault()only from version 119 (MDN compat data,beforeunload_event.preventdefault_activation). MDN's example also setsevent.returnValue = true"for legacy support, e.g. Chrome/Edge < 119". The TanStack handler web relies on does both (@tanstack/history1.162.4,src/index.ts:515-518).useEffectkeyed onactive, and remove it in the cleanup.InstrumentRendererContainer.tsx:107-115keeps the blocker mounted for the renderer's whole life and only flipsactivetofalsewhen the summary appears. A test that only mounts withactive={false}, as web's does atapps/web/src/components/__tests__/NavigationBlocker.test.tsx:54-57, does not prove the listener is removed. Write the issue's second test as a rerender fromtruetofalse. The effect also keepswindowout of the server render:apps/gateway/src/__tests__/entry-server.test.tsrenders the assignment page in node, and the container renders the blocker there too.beforeunloadunsupported on Safari for iOS (WebKit bug 219102). MDN also notes that mobile browsers do not fire it reliably, for example when the browser is closed from the app switcher. So on an iPhone, swipe-back and pull-to-refresh will still lose the answers after this lands. Covering that needs a saved draft, which should be its own issue. libui'sFormalready takesinitialValuesandsubscribe.onChange(6.16.1,src/components/Form/Form.tsx:45-61), soFormContentcould do it for forms without an upstream change. The open questions are sessionStorage or localStorage, and whether plaintext answers may be kept in the patient's browser at all.Related
COMPLETEsubmission (apps/gateway/src/routers/cap.router.ts:62,apps/gateway/src/routers/api.router.ts:151-153). Only theuseState(false)atapps/gateway/src/Root.tsx:40forgets it. PassingisAssignmentVerified(id)in theRootPropsbuilt atapps/gateway/src/routers/root.router.ts:45-53would spare a patient who reloads a second proof of work. That is a separate change, and it touches the same state as After a gateway restart, a patient's submit fails with "HTTP Request Failed 403" and the only way to re-verify discards their answers #1758.Root.tsxreload the page on 409. A refused PATCH throws beforesetIndex(2)inScalarInstrumentRendererand before the series moves to the next item, so the content step is still showing and the blocker is still active.location.reload()would then ask the patient to confirm. Whichever of the two lands second has to turn the blocker off before reloading.Tests
apps/gateway/src/components/__tests__/NavigationBlocker.test.tsx, with the// @vitest-environment happy-domdocblock: the issue's two cases (the second as a rerender), plus an unmount.apps/gateway/src/__tests__/Root.test.tsx: theInstrumentRenderermock already records its props inrenderer.props. Assert thatrenderer.props?.NavigationBlockeris the gateway component.@smoketest rather than a new one, since reaching Begin costs a Cap solve (gateway-assignment.spec.ts:88allows 120 s for it). Afterbegin()(line 89), wait for'dialog'while callinggatewayPage.close({ runBeforeUnload: true }). Assert the type is'beforeunload', thendismiss()it, which cancels the close, and carry on. Before the revisitgoto(line 114), attach adialoglistener that records and accepts each dialog, and assert afterwards that it recorded none. With no listener attached, playwright-core 1.63.0 accepts abeforeunloaddialog on its own, so the revisit as written would pass even if the blocker never turned off. The smoke test also runs on Firefox (testing/playwright.config.ts:31-38). Both browsers prompt only after a user gesture, which the Begin click supplies.packages/react-core/AGENTS.md:79-85names web's file as theNavigationBlockerimplementation. Mention the gateway'sbeforeunload-only one there in the same commit.Triage note written with Claude Code. Code references point at
main@d86740e.