Skip to content

Commit c0a2f89

Browse files
committed
fix(core): don't tear the runtime down on a bfcache pagehide
`pagehide` also fires with persisted === true when the document is frozen into the back-forward cache. That is not a death: `pageshow` restores the same document without re-running initialization. Tearing down there latched state.tornDown, stripped the listeners and injected styles, and closed the AudioContext — leaving a restored player permanently inert with nothing to bring it back. The destructive path is now gated to non-persisted page hides — the srcdoc-replacement case the listener was added for. A BFCached document is frozen by the browser, which suspends its AudioContext and media anyway, so skipping teardown while it sits there costs nothing, and the listener stays armed for the eventual real hide.
1 parent 70da7f8 commit c0a2f89

2 files changed

Lines changed: 63 additions & 5 deletions

File tree

‎packages/core/src/runtime/init.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,52 @@ describe("initSandboxRuntimeModular", () => {
652652
expect(injectedLink?.isConnected).toBe(false);
653653
});
654654

655+
describe("pagehide teardown", () => {
656+
async function initMinimalRuntime(): Promise<void> {
657+
const root = document.createElement("div");
658+
root.setAttribute("data-composition-id", "main");
659+
root.setAttribute("data-root", "true");
660+
root.setAttribute("data-start", "0");
661+
root.setAttribute("data-width", "1920");
662+
root.setAttribute("data-height", "1080");
663+
document.body.appendChild(root);
664+
window.__timelines = { main: createMockTimeline(3) };
665+
initSandboxRuntimeModular();
666+
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
667+
}
668+
669+
function firePageHide(persisted: boolean): void {
670+
const event = new Event("pagehide");
671+
Object.defineProperty(event, "persisted", { value: persisted });
672+
window.dispatchEvent(event);
673+
}
674+
675+
it("tears down on a real page hide", async () => {
676+
await initMinimalRuntime();
677+
expect(window.__hfRuntimeTeardown).toBeTypeOf("function");
678+
679+
firePageHide(false);
680+
681+
expect(window.__hfRuntimeTeardown).toBeNull();
682+
});
683+
684+
it("survives a bfcache page hide so a pageshow restore stays playable", async () => {
685+
await initMinimalRuntime();
686+
687+
// persisted === true means the document is frozen into the back-forward
688+
// cache, not destroyed — `pageshow` restores it WITHOUT re-running init,
689+
// so a teardown here would leave the player permanently inert.
690+
firePageHide(true);
691+
692+
expect(window.__hfRuntimeTeardown).toBeTypeOf("function");
693+
694+
// ...and the listener is still armed, so the eventual real hide still
695+
// reclaims the AudioContext and decoded buffers.
696+
firePageHide(false);
697+
expect(window.__hfRuntimeTeardown).toBeNull();
698+
});
699+
});
700+
655701
it("keeps compiled external composition hosts visible through their authored duration", async () => {
656702
const root = document.createElement("div");
657703
root.setAttribute("data-composition-id", "main");

‎packages/core/src/runtime/init.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3365,7 +3365,7 @@ export function initSandboxRuntimeModular(): void {
33653365
window.removeEventListener("beforeunload", state.beforeUnloadHandler);
33663366
state.beforeUnloadHandler = null;
33673367
}
3368-
window.removeEventListener("pagehide", teardown);
3368+
window.removeEventListener("pagehide", handlePageHide);
33693369
picker.disablePickMode();
33703370
for (const adapter of state.deterministicAdapters) {
33713371
if (!adapter || typeof adapter.revert !== "function") continue;
@@ -3417,15 +3417,27 @@ export function initSandboxRuntimeModular(): void {
34173417
window.__hfRuntimeTeardown = null;
34183418
}
34193419
};
3420-
window.__hfRuntimeTeardown = teardown;
3421-
state.beforeUnloadHandler = teardown;
3422-
window.addEventListener("beforeunload", state.beforeUnloadHandler);
34233420
// `pagehide` too: in an iframe whose document is replaced (an editor writing
34243421
// a new `srcdoc` on every edit), pagehide is the dependable unload signal.
34253422
// Without a teardown there, each discarded document keeps a live
34263423
// AudioContext + decoded-buffer cache until GC gets around to it — under
34273424
// rapid edits that transiently stacks hundreds of MB of PCM per reload.
34283425
// teardown() is idempotent (state.tornDown), so double-firing with
34293426
// beforeunload is harmless.
3430-
window.addEventListener("pagehide", teardown);
3427+
//
3428+
// But `pagehide` ALSO fires with `persisted: true` when the document enters
3429+
// the back-forward cache, and that is not a death — the very same document is
3430+
// restored by `pageshow` with no re-initialization. Tearing down there would
3431+
// leave the restored player permanently inert (tornDown latched, listeners and
3432+
// injected styles gone, AudioContext closed) with nothing to bring it back. A
3433+
// BFCached document is frozen by the browser, which suspends its AudioContext
3434+
// and media for us, so skipping teardown costs nothing while it sits there.
3435+
const handlePageHide = (event: PageTransitionEvent) => {
3436+
if (event.persisted) return;
3437+
teardown();
3438+
};
3439+
window.__hfRuntimeTeardown = teardown;
3440+
state.beforeUnloadHandler = teardown;
3441+
window.addEventListener("beforeunload", state.beforeUnloadHandler);
3442+
window.addEventListener("pagehide", handlePageHide);
34313443
}

0 commit comments

Comments
 (0)