Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!doctype html><html><body>hello</body></html>";
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 = "<!doctype html><html><body>after connect</body></html>";
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", "<!doctype html><html></html>");

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", "<!doctype html><html></html>");
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", "<!doctype html><html></html>");
document.body.appendChild(player);

expect(player.iframe.getAttribute("src")).toBe("/api/projects/foo/preview");
expect(player.iframe.getAttribute("srcdoc")).toBe("<!doctype html><html></html>");

player.remove();
});
});
23 changes: 22 additions & 1 deletion packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")!;
}

Expand All @@ -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();
Expand Down
Loading