Skip to content

Every interactive instrument's iframe stays in memory after it closes, retained by a theme observer on the parent document #1795

Open

Description

@joshunrau

Every interactive instrument's iframe stays in memory after it is removed, until the host page is reloaded. bootstrap.js runs inside the iframe and registers a MutationObserver on the parent's <html> element to mirror the theme, and nothing ever calls disconnect(). Per the DOM spec a node holds a strong reference to the observers registered on it. So the parent's <html>, which lives for the whole SPA session, keeps the iframe's observer alive. The observer keeps its callback alive, and the callback is a closure in the iframe's realm (it reads the iframe's document), so the iframe's whole global stays reachable. That global holds every module the task loaded (jsPsych, React, the instrument, its data URL assets) and, through frameElement, the iframe element with the full bundle in data-bundle. In a clinic session that administers several interactive tasks, or a series with interactive items, each finished task's memory accumulates. On the tablets used for remote and in-person sessions this grows until the tab is reloaded or killed.

Where

packages/runtime-internal/src/interactive/bootstrap.js:18-35:

// Synchronize the iframe theme with the parent document
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.attributeName === THEME_ATTRIBUTE) {
      // ...
        document.documentElement.setAttribute(THEME_ATTRIBUTE, updatedTheme);
      // ...
});

observer.observe(parent.document.documentElement, {
  attributes: true
});

Reproduce

I confirmed this in Chromium (Playwright's bundled build) with a parent page that mounts and removes five same-origin iframes. Each iframe runs the observer code above verbatim and stores a 2M-element array on its global, and the parent keeps a WeakRef to that array. After the iframes were removed I forced GC with HeapProfiler.collectGarbage:

  • without the observer: 0 of 5 iframe globals alive, heap 0.6 MB
  • with the observer as written: 5 of 5 iframe globals alive, heap 42.3 MB
  • with the observer plus addEventListener('pagehide', () => observer.disconnect()): 0 of 5 alive

In the app:

  1. In apps/web, start a session and complete an interactive instrument (for example the Breakout Task). Then complete another one, or a series with two interactive items.
  2. In DevTools > Memory, take a heap snapshot after each one and filter for Window or Detached.

Actual: each completed task leaves a detached iframe Window behind, retained through MutationObserver from the parent's HTMLHtmlElement, and heap usage grows with every task.
Expected: once its iframe is removed, nothing keeps an interactive instrument's realm alive.

Tests

packages/runtime-internal/src/interactive/__tests__/bootstrap.test.ts: it('should stop observing the parent theme on pagehide, so a removed iframe is not kept alive by the parent document'). Bootstrap against the existing fake parent document, dispatch pagehide on window, change the parent's data-mode, and assert that the iframe's data-mode no longer follows it.

e2e, in testing/src/specs/instrument-completion.spec.ts: after completing an interactive instrument, assert through CDP (HeapProfiler.collectGarbage, then Runtime.queryObjects on Window or a WeakRef probe) that the removed iframe's window has been collected.

Suggested fix

In bootstrap.js, add addEventListener('pagehide', () => observer.disconnect(), { once: true }). pagehide fires in the iframe when its element is removed. Also pass attributeFilter: [THEME_ATTRIBUTE] to observe, so the callback stops running on every other attribute change on the parent's <html>.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in library
    PerformanceSpeed, memory or scalability
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). bootstrap.js:18-35 has not changed since this was filed, and no open PR touches runtime-internal/src/interactive/. The frameElement observer in SynchronizedTranslator (packages/runtime-core/src/i18n.ts:111-120, called from :155) is not a second leak. It is registered on the <iframe> element, which becomes garbage together with the frame. In a scratch run, a task that also observed frameElement was collected once the pagehide fix was in.

    Changes to the suggested fix

    • Check persisted and drop { once: true }. When the host page goes into the back/forward cache, the task document also gets pagehide, with persisted: true (spec). Disconnecting then means the task stops following the theme after a bfcache restore. With once, that first pagehide would also use up the listener before the real removal. A removed iframe gets persisted: false (checked in Chromium 153 and Firefox 155).
      addEventListener('pagehide', (event) => {
        if (!event.persisted) {
          observer.disconnect();
        }
      });
    • With attributeFilter, delete the attributeName check at :21. It can no longer be false, and the coverage gate counts that branch. What is left duplicates :11-16, so make it one function.
    • The fix depends on non-standard behaviour. The spec says removing an iframe fires no pagehide. All three engines fire it anyway, and Browsers synchronously fire pagehide & unload, while the standard doesn't whatwg/html#10194 (open) records Chrome's interest in suppressing it. The e2e test below is what would catch that. A fix that does not depend on it: InteractiveContent pushes its useTheme() value (InteractiveContent.tsx:30) into iFrameRef.current.contentDocument from an effect, and bootstrap.js drops the observer. That touches packages/react-core as well. I would land the pagehide fix now and keep this as the fallback if the test ever fails.

    Tests

    Unit tests go in bootstrap.test.ts. One test dispatches a plain pagehide, changes the parent's data-mode, waits on a second parent observer as :133-141 does, and checks that the iframe kept its theme. A second test dispatches Object.assign(new Event('pagehide'), { persisted: true }) and checks that the sync continues. The property is set by hand because happy-dom aliases PageTransitionEvent to Event. Playwright launches Chromium with --disable-back-forward-cache, so only these tests can cover that branch. :133-141 keeps passing with attributeFilter, since happy-dom 20.11.15 honours it, and becomes the test for the filter.

    The e2e test does not fit instrument-completion.spec.ts. The only built-in interactive instrument, Breakout, calls done only when the game ends (packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:112, :189). Put it in playground.spec.ts beside the interactive share-link test at :188-213. When a task finishes in the preview, the summary replaces the task iframe and the preview document stays, which is the same setup as apps/web.

    1. Use a share-link instrument whose render(done) adds a button that calls done({}). Click Begin.
    2. Get the Frames with page.frame({ name: 'interactive-instrument' }) and page.frame({ url: /preview\.html/ }). The page model's previewFrame is a Locator, so add a helper to PlaygroundPage. Run taskFrame.evaluate(() => { globalThis.probe = {}; }), then store new WeakRef(iframe.contentWindow.probe) on the preview's global.
    3. Click the button with previewFrame.evaluate through iframe.contentDocument, then wait for the task iframe to disappear.
    4. Open context.newCDPSession(previewFrame) (the preview runs out of process). Then run expect.poll over HeapProfiler.collectGarbage followed by deref() === undefined. Do not tag it @smoke, because the firefox project has no CDP.

    I checked this setup in a scratch harness with a cross-site preview and a delayed removal. The current bootstrap kept the probe alive and the fixed one did not.

    Traps:

    • Do not click through frameLocator. When the instrument also observed frameElement, it kept the realm alive even with the fix.
    • Runtime.queryObjects cannot replace the probe. It needs an object id for the task realm's Window.prototype, and after the frame is removed that id fails with Cannot find context with specified id.

    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: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in libraryBugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPerformanceSpeed, memory or scalabilityPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions