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:321 — const messageListener = async function (event) {...}
:387 — await axios.post("/api/score/saveScoreAndState", ...), the multi-item path
:430 — the same call on the single-document path
:739 — createNewAttempt, 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:
- 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.
- 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.
- 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
Summary
AssignmentViewersaves a reader's work byawait axios.post(...)from inside itsSPLICE.reportScoreAndStatelistener. 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
messageevent directly rather than posting it — precisely so a listener still has a chance to act.AssignmentViewerreceives 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)AssignmentViewermountsDoenetViewerfrom@doenet/doenetml-iframewith noreportScoreAndStateCallback, and listens forSPLICE.reportScoreAndStateon its own window (:321, registered at:524). That is the synchronous path: the flush dispatches themessageevent directly on the parent window and this listener runs inline. One async hop remains — theaxios.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 passreportScoreAndStateCallbackdown to the iframe-mounted viewers (SingleDocActivity.tsx:145). doenet.org passes nomountPolicy, and@doenet/assignment-viewerdefaults tomode: "windowed"(activity-viewer.tsx:243), so that callback is delivered from inside the iframe as awindow.parent.postMessagerather 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 outerSPLICE.reportScoreAndState(activityStateReducer.ts:106,:154,:237,:292). So multi-item has three sequential async hops where single-doc has one.@doenet/assignment-viewerhas nopagehide,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:
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.posthalf, which applies to both types):apps/app/src/paths/AssignmentViewer.tsx:321—const messageListener = async function (event) {...}:387—await axios.post("/api/score/saveScoreAndState", ...), the multi-item path:430— the same call on the single-document path:739—createNewAttempt, reached from the listener on a new attemptIn
@doenet/assignment-viewer(the extra hops, multi-item only) — a separate change, and arguably a separate issue there:src/Activity/SingleDocActivity.tsx:145— passesreportScoreAndStateCallbackto the iframe viewer instead of letting it postSPLICE.reportScoreAndStatesrc/Viewer/Viewer.tsx:349— the callback, which dispatches to a reducersrc/Activity/activityStateReducer.ts:106,:154,:237,:292— where the outer report is finally postedThe constraint worth knowing before scoping this
The obvious fix —
navigator.sendBeacon, orfetch(..., { 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 existingaxios.post. Some directions, roughly in the order I would weigh them:otherStateblob, 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.navigator.sendBeaconreturnsfalse(queue full or over cap), fall back to the current post — better than nothing, though the fallback is exactly the case that gets cancelled.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