mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
perf(player): srcdoc composition switching for studio (#398)
## Summary
Adds `srcdoc` support to `<hyperframes-player>` 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
### `<hyperframes-player>` (`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.
This commit is contained in:
@@ -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,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();
|
||||
|
||||
Reference in New Issue
Block a user