From 21fbaefa1e0c9cca8f7629020657139145ea0f0e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 21 Apr 2026 11:26:35 -0700 Subject: [PATCH] perf(player): srcdoc composition switching for studio --- .../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 0a104820..6b268914 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 809c8ac9..63bc1854 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();