Skip to content

Commit 150d934

Browse files
authored
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.
1 parent ef3de5b commit 150d934

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

packages/player/src/hyperframes-player.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,3 +797,113 @@ describe("HyperframesPlayer seek() sync path", () => {
797797
expect(player._currentTime).toBe(11);
798798
});
799799
});
800+
801+
describe("HyperframesPlayer srcdoc attribute", () => {
802+
type PlayerInternal = HTMLElement & {
803+
iframe: HTMLIFrameElement;
804+
_ready: boolean;
805+
};
806+
807+
beforeEach(async () => {
808+
await import("./hyperframes-player.js");
809+
});
810+
811+
it("includes srcdoc in observedAttributes", () => {
812+
// `attributeChangedCallback` only fires for observed attributes. Without
813+
// this, runtime srcdoc swaps from studio would silently drop on the floor.
814+
const ctor = customElements.get("hyperframes-player") as
815+
| (typeof HTMLElement & { observedAttributes: string[] })
816+
| undefined;
817+
expect(ctor).toBeDefined();
818+
expect(ctor!.observedAttributes).toContain("srcdoc");
819+
});
820+
821+
it("forwards an initial srcdoc attribute to the iframe on connect", () => {
822+
// Studio's primary use case: render the player with composition HTML
823+
// already in hand, no network round-trip. Setting the attribute before
824+
// the element is connected must still apply on connect.
825+
const player = document.createElement("hyperframes-player") as PlayerInternal;
826+
const html = "<!doctype html><html><body>hello</body></html>";
827+
player.setAttribute("srcdoc", html);
828+
document.body.appendChild(player);
829+
830+
expect(player.iframe.getAttribute("srcdoc")).toBe(html);
831+
832+
player.remove();
833+
});
834+
835+
it("forwards a srcdoc attribute set after connect to the iframe", () => {
836+
// The composition-switching flow: same player element, new HTML.
837+
// Without `attributeChangedCallback` wiring this would no-op.
838+
const player = document.createElement("hyperframes-player") as PlayerInternal;
839+
document.body.appendChild(player);
840+
841+
const html = "<!doctype html><html><body>after connect</body></html>";
842+
player.setAttribute("srcdoc", html);
843+
844+
expect(player.iframe.getAttribute("srcdoc")).toBe(html);
845+
846+
player.remove();
847+
});
848+
849+
it("resets _ready when srcdoc changes so onIframeLoad replays setup", () => {
850+
// The ready flag gates probe intervals, controls hookup, and poster
851+
// tear-down. Switching documents must invalidate it so the next `load`
852+
// event re-runs that setup against the fresh window.
853+
const player = document.createElement("hyperframes-player") as PlayerInternal;
854+
document.body.appendChild(player);
855+
player._ready = true;
856+
857+
player.setAttribute("srcdoc", "<!doctype html><html></html>");
858+
859+
expect(player._ready).toBe(false);
860+
861+
player.remove();
862+
});
863+
864+
it("removes iframe.srcdoc when the attribute is removed so src can take over", () => {
865+
// Per HTML spec, iframe.srcdoc beats iframe.src whenever both are
866+
// present. Studio's fetch-fail fallback path needs srcdoc cleared so
867+
// setting src afterwards actually navigates to that URL.
868+
const player = document.createElement("hyperframes-player") as PlayerInternal;
869+
player.setAttribute("srcdoc", "<!doctype html><html></html>");
870+
document.body.appendChild(player);
871+
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
872+
873+
player.removeAttribute("srcdoc");
874+
875+
expect(player.iframe.hasAttribute("srcdoc")).toBe(false);
876+
877+
player.remove();
878+
});
879+
880+
it("treats an empty-string srcdoc as a deliberate empty document, not removal", () => {
881+
// `setAttribute("srcdoc", "")` and `removeAttribute("srcdoc")` send
882+
// different signals from the caller — empty string means "load a blank
883+
// doc," removal means "fall back to src." We have to distinguish them.
884+
const player = document.createElement("hyperframes-player") as PlayerInternal;
885+
document.body.appendChild(player);
886+
887+
player.setAttribute("srcdoc", "");
888+
889+
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
890+
expect(player.iframe.getAttribute("srcdoc")).toBe("");
891+
892+
player.remove();
893+
});
894+
895+
it("forwards both src and srcdoc to the iframe and lets the browser arbitrate", () => {
896+
// We deliberately don't strip src when srcdoc is set: the HTML spec
897+
// already says srcdoc wins, and keeping both lets the browser fall back
898+
// to src automatically if the embed re-renders without srcdoc.
899+
const player = document.createElement("hyperframes-player") as PlayerInternal;
900+
player.setAttribute("src", "/api/projects/foo/preview");
901+
player.setAttribute("srcdoc", "<!doctype html><html></html>");
902+
document.body.appendChild(player);
903+
904+
expect(player.iframe.getAttribute("src")).toBe("/api/projects/foo/preview");
905+
expect(player.iframe.getAttribute("srcdoc")).toBe("<!doctype html><html></html>");
906+
907+
player.remove();
908+
});
909+
});

packages/player/src/hyperframes-player.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,17 @@ const RUNTIME_CDN_URL =
2323

2424
class HyperframesPlayer extends HTMLElement {
2525
static get observedAttributes() {
26-
return ["src", "width", "height", "controls", "muted", "poster", "playback-rate", "audio-src"];
26+
return [
27+
"src",
28+
"srcdoc",
29+
"width",
30+
"height",
31+
"controls",
32+
"muted",
33+
"poster",
34+
"playback-rate",
35+
"audio-src",
36+
];
2737
}
2838

2939
private shadow: ShadowRoot;
@@ -155,6 +165,9 @@ class HyperframesPlayer extends HTMLElement {
155165
if (this.hasAttribute("poster")) this._setupPoster();
156166
if (this.hasAttribute("audio-src"))
157167
this._setupParentAudioFromUrl(this.getAttribute("audio-src")!);
168+
// srcdoc wins over src per HTML spec when both are set; mirror both attributes
169+
// so the browser applies the standard precedence rules.
170+
if (this.hasAttribute("srcdoc")) this.iframe.srcdoc = this.getAttribute("srcdoc")!;
158171
if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!;
159172
}
160173

@@ -180,6 +193,14 @@ class HyperframesPlayer extends HTMLElement {
180193
this.iframe.src = val;
181194
}
182195
break;
196+
case "srcdoc":
197+
// Distinguish removal (null) from empty-string ("") so callers can clear
198+
// srcdoc and let src take over. Always reset readiness; the iframe will
199+
// load a new document either way.
200+
this._ready = false;
201+
if (val !== null) this.iframe.srcdoc = val;
202+
else this.iframe.removeAttribute("srcdoc");
203+
break;
183204
case "width":
184205
this._compositionWidth = parseInt(val || "1920", 10);
185206
this._updateScale();

0 commit comments

Comments
 (0)