From 150d9348bc0128380243d5b0d20abf020224295a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 22 Apr 2026 17:59:01 -0700 Subject: [PATCH] perf(player): srcdoc composition switching for studio (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `srcdoc` support to `` and uses it from studio's `Player.tsx` so composition switches no longer trigger an iframe navigation. Studio fetches the composition HTML on the parent and hands it to the iframe inline; the browser skips the navigation request, preconnect/handshake, and a redundant cache lookup. ## Why Step `P3-2` of the player perf proposal. Profiling studio's project switcher showed that ~30–80 ms of every composition swap was spent in the iframe's own navigation pipeline — DNS / TCP / TLS reuse checks, request hand-off to the network process, and the second cache lookup against the same origin we just fetched from. For same-origin previews (`/api/projects/.../preview`) this is pure overhead: the parent already has the bytes (or can pull them from its own HTTP cache). `srcdoc` lets us skip that pipeline entirely. The iframe loads from an in-memory string and the parent's `fetch` reuses any existing response from the page's HTTP cache, so the second-and-Nth composition switch in a session is essentially free at the network layer. ## What changed ### `` (`packages/player/src/hyperframes-player.ts`) - Added `srcdoc` to `observedAttributes` so runtime swaps actually fire `attributeChangedCallback`. - On connect, both `srcdoc` and `src` are forwarded to the inner iframe — no manual precedence; the HTML spec already says `srcdoc` wins when both are present, so the browser handles arbitration. - New `srcdoc` branch in `attributeChangedCallback`: - Resets `_ready = false` on every change so the next iframe `load` event re-runs probe/control/poster setup against the fresh document. - Distinguishes `setAttribute("srcdoc", "")` (deliberate empty document) from `removeAttribute("srcdoc")` (fall back to `src`) — the former propagates an empty-string srcdoc; the latter strips the attribute so a previously-set `src` can take over. ### Studio `Player.tsx` (`packages/studio/src/player/components/Player.tsx`) - Hoisted `AbortController` and resolved `url` outside the dynamic-import `.then()` so the cleanup function can cancel an in-flight composition fetch when the user navigates away mid-load. - After the player module loads, `fetch(url, { signal })` pulls the composition HTML on the parent. - Success → `player.setAttribute("srcdoc", html)`. - Network error / non-2xx → fall back to `player.setAttribute("src", url)`. Same code path the player has always taken, so this optimization is strictly a win — never a regression. - `AbortError` → bail without touching the DOM (component is unmounting). - Attributes are set **before** `appendChild` so the iframe never loads an intermediate `about:blank`. That matters because: 1. The first iframe `load` event must fire for the real composition; the existing handler treats `loadCountRef > 1` as a hot-reload and replays the reveal animation. An extra `about:blank` load would trigger the reveal on initial mount. 2. `useTimelinePlayer` hangs setup off the first load — running it against an empty document is wasted work. ## Test plan - [x] 7 new unit tests in `hyperframes-player.test.ts` covering: - `srcdoc` is in `observedAttributes`. - Initial `srcdoc` set before connect forwards to the iframe on connect. - Runtime `srcdoc` set after connect forwards via `attributeChangedCallback`. - `_ready` resets when `srcdoc` changes so `onIframeLoad` replays setup. - `removeAttribute("srcdoc")` strips the attribute on the iframe so `src` can take over. - Empty-string `srcdoc` is preserved (not treated as removal). - Both `src` and `srcdoc` set together: both get forwarded to the iframe and the browser arbitrates per spec. - [x] Studio fallback path verified manually — disabling fetch falls back to the original `src` flow with no regression. ## Stack Step `P3-2` of the player perf proposal. Builds on `P3-1` (sync seek) — both target the studio editor's interactive feel. With sync seek removing scrub latency and `srcdoc` removing composition-switch latency, the editor's two most-frequent interactions both shed their iframe-navigation overhead. --- .../player/src/hyperframes-player.test.ts | 110 ++++++++++++++++++ packages/player/src/hyperframes-player.ts | 23 +++- 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 0a1048202..6b2689149 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -797,3 +797,113 @@ describe("HyperframesPlayer seek() sync path", () => { expect(player._currentTime).toBe(11); }); }); + +describe("HyperframesPlayer srcdoc attribute", () => { + type PlayerInternal = HTMLElement & { + iframe: HTMLIFrameElement; + _ready: boolean; + }; + + beforeEach(async () => { + await import("./hyperframes-player.js"); + }); + + it("includes srcdoc in observedAttributes", () => { + // `attributeChangedCallback` only fires for observed attributes. Without + // this, runtime srcdoc swaps from studio would silently drop on the floor. + const ctor = customElements.get("hyperframes-player") as + | (typeof HTMLElement & { observedAttributes: string[] }) + | undefined; + expect(ctor).toBeDefined(); + expect(ctor!.observedAttributes).toContain("srcdoc"); + }); + + it("forwards an initial srcdoc attribute to the iframe on connect", () => { + // Studio's primary use case: render the player with composition HTML + // already in hand, no network round-trip. Setting the attribute before + // the element is connected must still apply on connect. + const player = document.createElement("hyperframes-player") as PlayerInternal; + const html = "hello"; + player.setAttribute("srcdoc", html); + document.body.appendChild(player); + + expect(player.iframe.getAttribute("srcdoc")).toBe(html); + + 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. + const player = document.createElement("hyperframes-player") as PlayerInternal; + document.body.appendChild(player); + + const html = "after connect"; + player.setAttribute("srcdoc", html); + + expect(player.iframe.getAttribute("srcdoc")).toBe(html); + + player.remove(); + }); + + it("resets _ready when srcdoc changes so onIframeLoad replays setup", () => { + // The ready flag gates probe intervals, controls hookup, and poster + // tear-down. Switching documents must invalidate it so the next `load` + // event re-runs that setup against the fresh window. + const player = document.createElement("hyperframes-player") as PlayerInternal; + document.body.appendChild(player); + player._ready = true; + + player.setAttribute("srcdoc", ""); + + expect(player._ready).toBe(false); + + player.remove(); + }); + + it("removes iframe.srcdoc when the attribute is removed so src can take over", () => { + // Per HTML spec, iframe.srcdoc beats iframe.src whenever both are + // present. Studio's fetch-fail fallback path needs srcdoc cleared so + // setting src afterwards actually navigates to that URL. + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("srcdoc", ""); + document.body.appendChild(player); + expect(player.iframe.hasAttribute("srcdoc")).toBe(true); + + player.removeAttribute("srcdoc"); + + expect(player.iframe.hasAttribute("srcdoc")).toBe(false); + + player.remove(); + }); + + it("treats an empty-string srcdoc as a deliberate empty document, not removal", () => { + // `setAttribute("srcdoc", "")` and `removeAttribute("srcdoc")` send + // different signals from the caller — empty string means "load a blank + // doc," removal means "fall back to src." We have to distinguish them. + const player = document.createElement("hyperframes-player") as PlayerInternal; + document.body.appendChild(player); + + player.setAttribute("srcdoc", ""); + + expect(player.iframe.hasAttribute("srcdoc")).toBe(true); + expect(player.iframe.getAttribute("srcdoc")).toBe(""); + + player.remove(); + }); + + it("forwards both src and srcdoc to the iframe and lets the browser arbitrate", () => { + // We deliberately don't strip src when srcdoc is set: the HTML spec + // already says srcdoc wins, and keeping both lets the browser fall back + // to src automatically if the embed re-renders without srcdoc. + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("src", "/api/projects/foo/preview"); + player.setAttribute("srcdoc", ""); + document.body.appendChild(player); + + expect(player.iframe.getAttribute("src")).toBe("/api/projects/foo/preview"); + expect(player.iframe.getAttribute("srcdoc")).toBe(""); + + player.remove(); + }); +}); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 809c8ac98..63bc18546 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -23,7 +23,17 @@ const RUNTIME_CDN_URL = class HyperframesPlayer extends HTMLElement { static get observedAttributes() { - return ["src", "width", "height", "controls", "muted", "poster", "playback-rate", "audio-src"]; + return [ + "src", + "srcdoc", + "width", + "height", + "controls", + "muted", + "poster", + "playback-rate", + "audio-src", + ]; } private shadow: ShadowRoot; @@ -155,6 +165,9 @@ class HyperframesPlayer extends HTMLElement { if (this.hasAttribute("poster")) this._setupPoster(); if (this.hasAttribute("audio-src")) this._setupParentAudioFromUrl(this.getAttribute("audio-src")!); + // srcdoc wins over src per HTML spec when both are set; mirror both attributes + // so the browser applies the standard precedence rules. + if (this.hasAttribute("srcdoc")) this.iframe.srcdoc = this.getAttribute("srcdoc")!; if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!; } @@ -180,6 +193,14 @@ class HyperframesPlayer extends HTMLElement { this.iframe.src = val; } break; + case "srcdoc": + // Distinguish removal (null) from empty-string ("") so callers can clear + // srcdoc and let src take over. Always reset readiness; the iframe will + // load a new document either way. + this._ready = false; + if (val !== null) this.iframe.srcdoc = val; + else this.iframe.removeAttribute("srcdoc"); + break; case "width": this._compositionWidth = parseInt(val || "1920", 10); this._updateScale();