Skip to content

perf(player): srcdoc composition switching for studio#398

Open
vanceingalls wants to merge 1 commit intoperf/p3-1-sync-seek-same-originfrom
perf/p3-2-srcdoc-composition-switching
Open

perf(player): srcdoc composition switching for studio#398
vanceingalls wants to merge 1 commit intoperf/p3-1-sync-seek-same-originfrom
perf/p3-2-srcdoc-composition-switching

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 21, 2026

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

  • 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.
  • 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.

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good swap. Pre-fetching the composition HTML and handing it to the iframe via srcdoc is the classic "skip the navigation" win, and the AbortController wiring means a studio project-switch mid-fetch doesn't leave an orphan in-flight request firing into an unmounted component. The comments on the ordering (setting srcdoc/src before appendChild so the first load event fires for the real composition, not an intermediate about:blank) are load-bearing enough that I'm glad they're in-code rather than tribal.

Test coverage is thorough — initial attribute, post-connect change, removal distinguished from empty string, both-srcdoc-and-src-present letting the browser arbitrate via spec precedence, _ready reset on swap. The empty-string vs removal distinction is the kind of thing that only matters once a caller actually exercises it, and nice to see it pinned now.

Non-blocking observations:

  1. The studio fetch falls back to src on any non-AbortError failure. For consistency, the fallback branch could emit an analytics/perf metric (leveraging #393) so we can spot how often the srcdoc fast path is actually used in the wild. Not a blocker.

  2. fetch(url, { signal }) then player.setAttribute("srcdoc", html) copies the HTML text into an attribute; for large compositions (hundreds of KB inline) this adds a ~1 copy plus DOM attribute cost. Probably still a net win vs network navigation, but worth remembering once compositions grow.

Approved.

Rames Jusso

@vanceingalls vanceingalls force-pushed the perf/p3-2-srcdoc-composition-switching branch from 3e9f8fc to a3f8cef Compare April 22, 2026 00:43
@vanceingalls vanceingalls force-pushed the perf/p3-1-sync-seek-same-origin branch from 9c5a23d to 048a3c0 Compare April 22, 2026 00:43
@vanceingalls vanceingalls force-pushed the perf/p3-2-srcdoc-composition-switching branch from a3f8cef to d77730f Compare April 22, 2026 00:53
@vanceingalls vanceingalls force-pushed the perf/p3-1-sync-seek-same-origin branch from 048a3c0 to 61189b6 Compare April 22, 2026 00:53
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the stack locally with agent-browser sanity checks in Studio plus repeated bun run player:perf -- --mode=measure --scenarios=load,fps,scrub,drift,parity --runs=2 runs on both this stack and main artifacts.

The implementation here looks directionally good: srcdoc support in <hyperframes-player> is wired correctly, the AbortController cleanup is correct, and I don’t see a correctness issue with the fetch-then-inline path for same-origin previews.

The important caveat is evidentiary, not correctness: the perf harness that shows the wins is still measuring the standalone /host.html?fixture=... player flow, not the Studio Player.tsx fetch -> srcdoc preview path introduced in this PR. So I buy the optimization, but I would not over-claim measured Studio impact from the current numbers alone. If we want this PR to carry a strong performance claim, we should either add a Studio-specific switch benchmark later or narrow the wording to “expected Studio composition-switch improvement” rather than presenting it as already proven by the current harness.

Not blocking this PR on that, but I wanted it called out explicitly because the distinction matters at review time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants