Skip to content

Gateway lets a patient reload or leave mid-instrument without warning, discarding every unsaved answer #1726

Open

Description

@joshunrau

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

  1. Open an assignment link, verify, click Begin and answer most of a form.
  2. 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') and it('should let the page unload freely once the blocker is inactive').
  • e2e, testing/src/specs/gateway-assignment.spec.ts: after Begin, call gatewayPage.close({ runBeforeUnload: true }) and assert that a beforeunload dialog 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

  1. added
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing under apps/gateway/src or packages/react-core/src/components/InstrumentRenderer has changed since this was filed.

    Notes on the suggested fix

    • Set event.returnValue as well as calling preventDefault(). Chrome and Android WebView honour preventDefault() only from version 119 (MDN compat data, beforeunload_event.preventdefault_activation). MDN's example also sets event.returnValue = true "for legacy support, e.g. Chrome/Edge < 119". The TanStack handler web relies on does both (@tanstack/history 1.162.4, src/index.ts:515-518).
    • Add the listener in a useEffect keyed on active, and remove it in the cleanup. InstrumentRendererContainer.tsx:107-115 keeps the blocker mounted for the renderer's whole life and only flips active to false when the summary appears. A test that only mounts with active={false}, as web's does at apps/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 from true to false. The effect also keeps window out of the server render: apps/gateway/src/__tests__/entry-server.test.ts renders the assignment page in node, and the container renders the blocker there too.
    • iPhones get no prompt. The same compat data marks beforeunload unsupported 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's Form already takes initialValues and subscribe.onChange (6.16.1, src/components/Form/Form.tsx:45-61), so FormContent could 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

    Tests

    • apps/gateway/src/components/__tests__/NavigationBlocker.test.tsx, with the // @vitest-environment happy-dom docblock: the issue's two cases (the second as a rerender), plus an unmount.
    • apps/gateway/src/__tests__/Root.test.tsx: the InstrumentRenderer mock already records its props in renderer.props. Assert that renderer.props?.NavigationBlocker is the gateway component.
    • e2e: add both checks to the existing @smoke test rather than a new one, since reaching Begin costs a Cap solve (gateway-assignment.spec.ts:88 allows 120 s for it). After begin() (line 89), wait for 'dialog' while calling gatewayPage.close({ runBeforeUnload: true }). Assert the type is 'beforeunload', then dismiss() it, which cancels the close, and carry on. Before the revisit goto (line 114), attach a dialog listener that records and accepts each dialog, and assert afterwards that it recorded none. With no listener attached, playwright-core 1.63.0 accepts a beforeunload dialog 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-85 names web's file as the NavigationBlocker implementation. Mention the gateway's beforeunload-only one there in the same commit.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Gatewayapps/gateway (patient-facing app for remote assignments)Difficulty: LowIsolated change, about 2 hours or lessEnhancementType: an improvement to an existing feature, including performanceGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions