Skip to content

Assignment saves issued on page unload are cancelled before they reach the server #3034

Description

@dqnykamp

Summary

AssignmentViewer saves a reader's work by await axios.post(...) from inside its SPLICE.reportScoreAndState listener. That works while the page is alive, but a request issued while the document is being torn down is cancelled — so when a reader closes the tab, types a new URL, or follows an external link, the save that was meant to catch their last work never reaches the server.

This has not been reachable until now, because the viewer had nothing to report at that moment. Doenet/DoenetML#1734 (in review) changes that: the viewer now hands the host whatever its 60-second report throttle is holding back when the page hides, and delivers it synchronously — dispatching the message event directly rather than posting it — precisely so a listener still has a chance to act. AssignmentViewer receives that report. It just cannot finish writing it.

What that PR does and does not close for doenet.org

This differs by assignment type, and only one of the two is on the synchronous delivery path.

Single documents (loaderData.type === "singleDoc", AssignmentViewer.tsx:586)

AssignmentViewer mounts DoenetViewer from @doenet/doenetml-iframe with no reportScoreAndStateCallback, and listens for SPLICE.reportScoreAndState on its own window (:321, registered at :524). That is the synchronous path: the flush dispatches the message event directly on the parent window and this listener runs inline. One async hop remains — the axios.post — and that is the one this issue is about.

Multi-item activities (DoenetActivityViewer, AssignmentViewer.tsx:618)

These go through @doenet/assignment-viewer, which does pass reportScoreAndStateCallback down to the iframe-mounted viewers (SingleDocActivity.tsx:145). doenet.org passes no mountPolicy, and @doenet/assignment-viewer defaults to mode: "windowed" (activity-viewer.tsx:243), so that callback is delivered from inside the iframe as a window.parent.postMessage rather than as a direct call. It is a queued task, so an unload discards it — the viewer's synchronous flush is bypassed entirely for this path, and the chain breaks at its first hop rather than its last.

Two further deferrals sit behind it even when it does land: the callback dispatches into a React reducer (Viewer.tsx:349), and the reducer emits the outer SPLICE.reportScoreAndState (activityStateReducer.ts:106, :154, :237, :292). So multi-item has three sequential async hops where single-doc has one. @doenet/assignment-viewer has no pagehide, visibilitychange, or beacon handling of its own; it adds no throttle of its own either, so the extra cost is hops rather than staleness.

Either type

Closed by the DoenetML change alone — the page is still alive, so every hop completes:

  • Switching tabs, minimizing, or backgrounding on mobile (visibilitychange → hidden). This is where most real loss happens, since a backgrounded mobile tab can be discarded without further warning.

Still open — closing the tab, typing a new URL, following an external link. Single-doc gets the payload synchronously and loses it at the write; multi-item loses it one hop earlier.

Unverified — in-app navigation away from an assignment. I confirmed the flush survives an iframe detach for a plain message-channel viewer, so single-doc looks covered, but I have not tested either type against doenet.org's own listener teardown, and for multi-item the reducer dispatch would be landing on an unmounting component.

Where

In this repo (the axios.post half, which applies to both types):

  • apps/app/src/paths/AssignmentViewer.tsx:321const messageListener = async function (event) {...}
  • :387await axios.post("/api/score/saveScoreAndState", ...), the multi-item path
  • :430 — the same call on the single-document path
  • :739createNewAttempt, reached from the listener on a new attempt

In @doenet/assignment-viewer (the extra hops, multi-item only) — a separate change, and arguably a separate issue there:

  • src/Activity/SingleDocActivity.tsx:145 — passes reportScoreAndStateCallback to the iframe viewer instead of letting it post SPLICE.reportScoreAndState
  • src/Viewer/Viewer.tsx:349 — the callback, which dispatches to a reducer
  • src/Activity/activityStateReducer.ts:106, :154, :237, :292 — where the outer report is finally posted

The constraint worth knowing before scoping this

The obvious fix — navigator.sendBeacon, or fetch(..., { keepalive: true }) — carries a 64 KB body cap in both, and it is enforced across all in-flight keepalive requests, not per request. Serialized activity state can exceed that comfortably, so this is not a drop-in swap for the existing axios.post. Some directions, roughly in the order I would weigh them:

  1. A smaller unload-time payload. Send only what the current report changed — the one doc state and its item score — rather than the whole otherState blob, to a lightweight endpoint that merges rather than replaces. Keeps the normal path as it is and only pays for the difference on the way out.
  2. Beacon with a server-side size fallback. Attempt the beacon and, if navigator.sendBeacon returns false (queue full or over cap), fall back to the current post — better than nothing, though the fallback is exactly the case that gets cancelled.
  3. Compress before sending. Buys headroom without changing the endpoint's shape, but the cap is a ceiling rather than a budget, so it narrows the problem rather than removing it.

Option 1 is the only one that actually bounds the payload; the others make it smaller and hope.

Ordering

Nothing here is useful until Doenet/DoenetML#1734 lands and doenet.org picks up a DoenetML version containing it — before then the viewer has nothing to deliver at page-hide time, so the write has nothing to save. Worth doing in that order rather than in parallel.

Verifying

The DoenetML side has an end-to-end regression test that drives a real same-origin navigation (packages/test-cypress/cypress/e2e/DocViewer/flushStateOnPageHide.cy.js), which is a reasonable model. On this end the check is narrower: type into an assignment, wait past the one-second save debounce but well inside the 60-second report throttle, close the page, and assert the server has the typed work. A test that only reloads without asserting server state will pass either way, since the report is delivered in both cases — it is the write that differs.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    Priority

    Low

    Effort

    High

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions