Summary
Reports from the inner viewers reach the host through the reportScoreAndStateCallback prop rather than the SPLICE.reportScoreAndState message, and that is the one shape a page-hide flush cannot deliver. When a reader closes the tab, types a new URL, or follows an external link, work they did inside the viewer's 60-second report throttle is lost — even though the viewer handed it over in time.
Doenet/DoenetML#1734 (in review) makes the viewer flush whatever its throttle is holding back when the page hides, and deliver it synchronously — dispatching the message event directly on the target window rather than posting it — because pagehide gives no budget for a queued task. A host listening for SPLICE.reportScoreAndState on its own window gets that report inline and can still act on it. A host that passed reportScoreAndStateCallback does not: from inside the iframe that callback is a cross-boundary post, and the unloading document is destroyed before the task runs.
This activity viewer is on the second path.
Where
src/Activity/SingleDocActivity.tsx:145 — passes reportScoreAndStateCallback to the iframe-mounted DoenetViewer
src/Viewer/Viewer.tsx:349 — the callback, which dispatches into a useReducer (:130)
src/Activity/activityStateReducer.ts:106, :154, :237, :292 — where the outer SPLICE.reportScoreAndState is finally posted
Because mountPolicy defaults to mode: "windowed" (src/activity-viewer.tsx:243), the callback is delivered from inside the iframe as a window.parent.postMessage rather than a Comlink proxy. Both are queued tasks, so both are discarded on unload; the windowed form is simply the one that applies by default.
The chain, and why the extra hops matter
After the viewer's synchronous flush, a report crosses three deferrals before reaching a persistence host:
- the windowed
window.parent.postMessage out of the iframe,
- the reducer dispatch — the reducer runs during a subsequent React render, not at call time,
- the reducer's own
window.postMessage of the aggregated report.
A host embedding a single document directly has none of these; it receives the flush inline. So this viewer converts a case the DoenetML change closes into one it cannot, and it breaks at the first hop rather than the last — meaning nothing downstream can compensate, however that host is written.
Concretely on doenet.org: single-document assignments mount @doenet/doenetml-iframe's DoenetViewer directly and listen on the window, so they are on the synchronous path; multi-item assignments come through here and are not. Tracked on that end at Doenet/DoenetApps#3034, where fixing the host's own write is necessary but — for multi-item — not sufficient without a change here.
Directions
- Let the inner viewers post
SPLICE.reportScoreAndState and listen for it here. Removing the callback prop puts them back on the synchronous path, and this viewer can aggregate from a message listener. It also matches what a single-document host already does. Worth checking against the windowed-mounting ordering guarantee that made the callback attractive: iframe-viewer-index.ts routes windowed reports over the window channel precisely so a report is seen before the park acknowledgement, which the same listener would preserve.
- Add page-hide handling here. Keep the prop, but have this viewer flush its own aggregated state on
pagehide and visibilitychange → hidden, emitting the outer report synchronously. Leaves hop 1 in place, so it only helps if the inner viewers' latest reports have already landed — which for the throttle window is exactly what is in doubt.
- Emit the outer report outside the reducer. Independent of the above and worth doing regardless:
window.postMessage from inside a useReducer reducer is a side effect in a function React may call more than once, and it is what makes hop 3 deferred.
The first is the only one that puts this viewer back on the path the flush was built for.
What is unaffected
- Switching tabs, minimizing, or backgrounding on mobile (
visibilitychange → hidden): the page is alive, so all three hops complete and the work is saved. That covers the bulk of real-world loss, including a backgrounded mobile tab being discarded.
- Parked viewers: parking already sends
SPLICE.flushState and waits for the acknowledgement, so a viewer scrolled out of view has nothing pending. It is the live one at unload time that this is about.
Verifying
The check is narrower than it looks: a report is delivered whether or not this is fixed, so a test that only asserts the host received something passes either way. It has to assert the host's record after the page is actually gone. DoenetML has an end-to-end model for that in packages/test-cypress/cypress/e2e/DocViewer/flushStateOnPageHide.cy.js — a real same-origin navigation, with work typed past the one-second save debounce but well inside the 60-second report throttle.
Nothing here is worth doing until Doenet/DoenetML#1734 lands and this package picks up a DoenetML version containing it; before then the inner viewers have nothing to deliver at page-hide time.
🤖 Generated with Claude Code
Summary
Reports from the inner viewers reach the host through the
reportScoreAndStateCallbackprop rather than theSPLICE.reportScoreAndStatemessage, and that is the one shape a page-hide flush cannot deliver. When a reader closes the tab, types a new URL, or follows an external link, work they did inside the viewer's 60-second report throttle is lost — even though the viewer handed it over in time.Doenet/DoenetML#1734 (in review) makes the viewer flush whatever its throttle is holding back when the page hides, and deliver it synchronously — dispatching the
messageevent directly on the target window rather than posting it — becausepagehidegives no budget for a queued task. A host listening forSPLICE.reportScoreAndStateon its own window gets that report inline and can still act on it. A host that passedreportScoreAndStateCallbackdoes not: from inside the iframe that callback is a cross-boundary post, and the unloading document is destroyed before the task runs.This activity viewer is on the second path.
Where
src/Activity/SingleDocActivity.tsx:145— passesreportScoreAndStateCallbackto the iframe-mountedDoenetViewersrc/Viewer/Viewer.tsx:349— the callback, which dispatches into auseReducer(:130)src/Activity/activityStateReducer.ts:106,:154,:237,:292— where the outerSPLICE.reportScoreAndStateis finally postedBecause
mountPolicydefaults tomode: "windowed"(src/activity-viewer.tsx:243), the callback is delivered from inside the iframe as awindow.parent.postMessagerather than a Comlink proxy. Both are queued tasks, so both are discarded on unload; the windowed form is simply the one that applies by default.The chain, and why the extra hops matter
After the viewer's synchronous flush, a report crosses three deferrals before reaching a persistence host:
window.parent.postMessageout of the iframe,window.postMessageof the aggregated report.A host embedding a single document directly has none of these; it receives the flush inline. So this viewer converts a case the DoenetML change closes into one it cannot, and it breaks at the first hop rather than the last — meaning nothing downstream can compensate, however that host is written.
Concretely on doenet.org: single-document assignments mount
@doenet/doenetml-iframe'sDoenetViewerdirectly and listen on the window, so they are on the synchronous path; multi-item assignments come through here and are not. Tracked on that end at Doenet/DoenetApps#3034, where fixing the host's own write is necessary but — for multi-item — not sufficient without a change here.Directions
SPLICE.reportScoreAndStateand listen for it here. Removing the callback prop puts them back on the synchronous path, and this viewer can aggregate from amessagelistener. It also matches what a single-document host already does. Worth checking against the windowed-mounting ordering guarantee that made the callback attractive:iframe-viewer-index.tsroutes windowed reports over the window channel precisely so a report is seen before the park acknowledgement, which the same listener would preserve.pagehideandvisibilitychange→ hidden, emitting the outer report synchronously. Leaves hop 1 in place, so it only helps if the inner viewers' latest reports have already landed — which for the throttle window is exactly what is in doubt.window.postMessagefrom inside auseReducerreducer is a side effect in a function React may call more than once, and it is what makes hop 3 deferred.The first is the only one that puts this viewer back on the path the flush was built for.
What is unaffected
visibilitychange→ hidden): the page is alive, so all three hops complete and the work is saved. That covers the bulk of real-world loss, including a backgrounded mobile tab being discarded.SPLICE.flushStateand waits for the acknowledgement, so a viewer scrolled out of view has nothing pending. It is the live one at unload time that this is about.Verifying
The check is narrower than it looks: a report is delivered whether or not this is fixed, so a test that only asserts the host received something passes either way. It has to assert the host's record after the page is actually gone. DoenetML has an end-to-end model for that in
packages/test-cypress/cypress/e2e/DocViewer/flushStateOnPageHide.cy.js— a real same-origin navigation, with work typed past the one-second save debounce but well inside the 60-second report throttle.Nothing here is worth doing until Doenet/DoenetML#1734 lands and this package picks up a DoenetML version containing it; before then the inner viewers have nothing to deliver at page-hide time.
🤖 Generated with Claude Code