Skip to content

Reports routed through reportScoreAndStateCallback are lost when the page unloads #50

Description

@dqnykamp

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:

  1. the windowed window.parent.postMessage out of the iframe,
  2. the reducer dispatch — the reducer runs during a subsequent React render, not at call time,
  3. 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

  1. 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.
  2. 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.
  3. 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

Activity

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

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions