From 22b81006138168ac3d673db860283ecd8df76fb6 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 20:06:40 -0700 Subject: [PATCH] fix(player): defer initial iframe navigation --- .../player/src/hyperframes-player.test.ts | 32 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 6 ++++ 2 files changed, 38 insertions(+) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 7143f1b47..43063fd60 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -525,6 +525,7 @@ describe("HyperframesPlayer shader transition options", () => { const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-capture-scale", "0.5"); player.setAttribute("shader-loading", "player"); + document.body.appendChild(player); player.setAttribute("src", "/api/projects/demo/preview?x=1#stage"); const url = new URL(player.iframeElement.src); @@ -539,6 +540,7 @@ describe("HyperframesPlayer shader transition options", () => { const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-capture-scale", "0.5"); player.setAttribute("shader-loading", "player"); + document.body.appendChild(player); player.setAttribute( "srcdoc", '', @@ -1512,6 +1514,36 @@ describe("HyperframesPlayer srcdoc attribute", () => { player.remove(); }); + it("does not navigate initial srcdoc before the runtime listener is connected", () => { + // React assigns custom-element attributes before inserting the element. If the observed + // attribute callback navigates the child iframe immediately, a fast srcdoc runtime can post + // its one-shot `ready` message before connectedCallback subscribes to `window.message`. + // Retained runtime data then waits forever and a caption style appears stuck on its bootstrap + // frame. The connect path owns the first navigation; attributeChangedCallback owns only + // subsequent swaps. + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("srcdoc", "deferred"); + + expect(player.iframe.hasAttribute("srcdoc")).toBe(false); + + document.body.appendChild(player); + expect(player.iframe.getAttribute("srcdoc")).toContain("deferred"); + + player.remove(); + }); + + it("does not navigate initial src before the runtime listener is connected", () => { + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("src", "/api/projects/deferred/preview"); + + expect(player.iframe.hasAttribute("src")).toBe(false); + + document.body.appendChild(player); + expect(player.iframe.getAttribute("src")).toBe("/api/projects/deferred/preview"); + + player.remove(); + }); + it("forwards a srcdoc attribute set after connect to the iframe", () => { // The composition-switching flow: same player element, new HTML. // Without `attributeChangedCallback` wiring this would no-op. diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 7aba19340..d7067c125 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -218,6 +218,11 @@ class HyperframesPlayer extends HTMLElement { attributeChangedCallback(name: string, oldVal: string | null, val: string | null) { switch (name) { case "src": + // Custom-element attributes are normally assigned before insertion (React does this for + // every render). Navigating the inner iframe here would let its one-shot runtime `ready` + // message fire before connectedCallback installs the parent message listener. Initial + // attributes are applied below by connectedCallback; only live changes navigate here. + if (!this.isConnected) break; if (val) { this._ready = false; this._runtimeBridgeReady = false; @@ -228,6 +233,7 @@ class HyperframesPlayer extends HTMLElement { } break; case "srcdoc": + if (!this.isConnected) break; this._ready = false; this._runtimeBridgeReady = false; this._rejectAllRuntimeDataDeliveries(