perf(studio): start the opening film's preview from the page's first script

Studio created the preview frame only after its 4.7 MB app loaded and mounted,
about 0.9 s into a cold open. A small first script now starts the preview of the
project in the URL right away, and the preview pane adopts that running player
when it mounts instead of loading the film a second time.
This commit is contained in:
Miguel Ángel
2026-09-23 06:37:24 -04:00
parent bc4ff23560
commit a9dc582068
11 changed files with 315 additions and 62 deletions
@@ -216,6 +216,10 @@ class HyperframesPlayer extends HTMLElement {
}
}
// Element.moveBefore() calls this instead of disconnected + connected, so a host can
// move a loaded player without reloading its iframe.
connectedMoveCallback() {}
disconnectedCallback() {
this._sendControl("pause");
this._stopIframeMedia();
+1 -1
View File
@@ -8,6 +8,6 @@
</head>
<body>
<div data-hf-id="hf-aph5" id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/boot.ts"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
// The page's only static script: start the opening project's preview, then load Studio.
// Everything Studio renders arrives with ./main, so the preview's request does not wait on it.
import "@hyperframes/player";
import { dropBootPreview, startBootPreview } from "./player/lib/bootPreview";
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
import { parseProjectIdFromHash } from "./utils/projectRouting";
const openingProject = parseProjectIdFromHash(window.location.hash);
prefetchPreviewForHash(window.location.hash, startBootPreview(window.location.hash));
window.addEventListener("hashchange", () => {
if (parseProjectIdFromHash(window.location.hash) !== openingProject) dropBootPreview();
prefetchPreviewForHash(window.location.hash);
});
void import("./main");
+6 -3
View File
@@ -5,12 +5,15 @@ import { StudioApp } from "./App";
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
import { readIconTokens } from "./styles/iconTokens";
import { trackStudioEvent } from "./utils/studioTelemetry";
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
import { dropBootPreview } from "./player/lib/bootPreview";
import { whenPreviewBooted } from "./player/store/playerStore";
import { parseProjectIdFromHash } from "./utils/projectRouting";
import "./styles/studio.css";
prefetchPreviewForHash(window.location.hash);
window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
trackStudioEvent("session_start");
// ponytail: a project that never mounts a Player (unreachable) keeps its parked 404 page.
const openingProject = parseProjectIdFromHash(window.location.hash);
if (openingProject) void whenPreviewBooted(openingProject).then(dropBootPreview);
function errorProps(value: unknown): {
error_message: string;
@@ -10,6 +10,7 @@ import {
shouldShowCompositionLoadingOverlay,
} from "./Player";
import { usePlayerStore } from "../store/playerStore";
import { dropBootPreview, startBootPreview } from "../lib/bootPreview";
vi.mock("@hyperframes/player", () => ({}));
@@ -347,6 +348,63 @@ describe("ready to show", () => {
});
});
describe("boot preview", () => {
const moveBefore = function (this: Element, node: Node, child: Node | null) {
this.insertBefore(node, child);
};
const withMoveBefore = () =>
Object.defineProperty(Element.prototype, "moveBefore", {
value: moveBefore,
configurable: true,
});
afterEach(() => {
dropBootPreview();
Reflect.deleteProperty(Element.prototype, "moveBefore");
});
it("adopts the preview the page started without navigating it again, and catches up on its lifecycle", async () => {
withMoveBefore();
expect(startBootPreview("#project/demo")).toBe(true);
const booted = document.querySelector<TestHyperframesPlayer>("hyperframes-player")!;
booted.iframeElement.dispatchEvent(new Event("load"));
booted.dispatchEvent(new Event("ready"));
booted.dispatchEvent(new Event("painted"));
lifecycleLog = [];
const onLoad = vi.fn();
const onReadyToShowChange = vi.fn();
const { host, player } = await mountPlayer({ onLoad, onReadyToShowChange });
await twoFrames();
expect(player).toBe(booted);
expect(host.contains(booted)).toBe(true);
expect(document.querySelectorAll("hyperframes-player")).toHaveLength(1);
expect(lifecycleLog).not.toContain("src");
expect(onLoad).toHaveBeenCalledTimes(1);
expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
});
it("leaves a boot preview of another document parked for the Player that loads it", async () => {
withMoveBefore();
startBootPreview("#project/other");
const booted = document.querySelector("hyperframes-player");
const { player } = await mountPlayer();
expect(player).not.toBe(booted);
expect(lifecycleLog).toContain("src");
expect(booted?.isConnected).toBe(true);
dropBootPreview();
expect(booted?.isConnected).toBe(false);
});
it("starts nothing when the browser cannot move a loaded iframe", () => {
expect(startBootPreview("#project/demo")).toBe(false);
expect(document.querySelector("hyperframes-player")).toBeNull();
});
});
describe("composition loading overlay", () => {
it("shows while the composition is loading", () => {
expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
@@ -1,26 +1,24 @@
import { buildProjectApiPath } from "../../utils/projectRouting";
import { forwardRef, useEffect, useRef, useState } from "react";
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
import { useMountEffect } from "../../hooks/useMountEffect";
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { HyperframesLoader } from "../../components/ui";
import { usePlayerStore } from "../store/playerStore";
// Importing "@hyperframes/player" registers a class extending HTMLElement at
// module load, which throws under SSR, hence the dynamic import behind a
// `typeof window` guard. Kicking it here rather than in the mount effect puts
// the chunk request in flight before the shell's first layout. Clearing the memo
// on rejection stops one failure poisoning every later mount; the browser's
// module map still caches a failed fetch, so recovery is a page reload.
let playerModule: Promise<unknown> | null = null;
export function loadPlayerModule(): Promise<unknown> {
playerModule ??= import("@hyperframes/player").catch((err: unknown) => {
playerModule = null;
throw err;
});
return playerModule;
}
import {
adoptBootPreview,
listenPreviewPlayer,
type BootPreview,
type PreviewPlayerEventType,
} from "../lib/bootPreview";
import {
createPreviewPlayer,
loadPlayerModule,
previewSrc,
setPreviewPlayerOrientation,
type HyperframesPlayerElement,
} from "../lib/previewPlayerElement";
// Kicking the player chunk here rather than in the mount effect puts the request in
// flight before the shell's first layout.
if (typeof window !== "undefined") void loadPlayerModule().catch(() => {});
interface PlayerProps {
@@ -37,10 +35,6 @@ interface PlayerProps {
suppressLoadingOverlay?: boolean;
}
interface HyperframesPlayerElement extends HTMLElement {
iframeElement: HTMLIFrameElement;
}
const MEDIA_HAVE_FUTURE_DATA = 3;
const MEDIA_NETWORK_NO_SOURCE = 3;
@@ -73,6 +67,30 @@ export function readPreviewErrorMessage(event: Event): string {
: DEFAULT_PREVIEW_ERROR;
}
function assignIframeRef(ref: React.ForwardedRef<HTMLIFrameElement>, iframe: HTMLIFrameElement) {
if (typeof ref === "function") ref(iframe);
else if (ref) ref.current = iframe;
}
/** Starts the preview in `container` unless it is the adopted boot preview; returns its start time. */
function connectPreviewPlayer(
container: HTMLElement,
player: HyperframesPlayerElement,
src: string,
booted: BootPreview | null,
portrait?: boolean,
): number {
if (booted) {
// Already loading since the page's first script: keep its document.
setPreviewPlayerOrientation(player, portrait);
return booted.startedAt;
}
const startedAt = performance.now();
player.setAttribute("src", src);
container.appendChild(player);
return startedAt;
}
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
const root = player.shadowRoot;
if (!root) return;
@@ -195,9 +213,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const container = containerRef.current;
if (!container) return;
const previewSource =
directUrl || (projectId ? buildProjectApiPath(projectId, "/preview") : null);
if (!previewSource) return;
const src = previewSrc(projectId, directUrl);
if (!src) return;
let canceled = false;
let cleanup: (() => void) | undefined;
@@ -205,11 +222,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
void loadPlayerModule().then(() => {
if (canceled) return;
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
const srcUrl = new URL(previewSource, window.location.origin);
applyPreviewVariablesToUrl(srcUrl);
const src = srcUrl.pathname + srcUrl.search;
const booted = adoptBootPreview(container, src);
const player = booted?.player ?? createPreviewPlayer(portrait);
const retryPreview = () => {
retryCountRef.current += 1;
const retryUrl = new URL(src, window.location.origin);
@@ -302,32 +316,21 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
// Attach lifecycle listeners before assigning src or connecting the
// custom element. A warm local iframe can otherwise finish before
// Studio observes its load and never initialize the timeline.
iframe.addEventListener("load", handleLoad);
const handlers: Record<PreviewPlayerEventType, (event: Event) => void> = {
load: handleLoad,
ready: handleReady,
painted: handlePainted,
error: handleError,
shadertransitionstate: handleShaderTransitionState,
};
const stopListening = listenPreviewPlayer(player, (type, event) => handlers[type](event));
player.addEventListener("click", preventToggle, { capture: true });
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
player.addEventListener("ready", handleReady);
player.addEventListener("painted", handlePainted);
player.addEventListener("error", handleError);
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
if (typeof ref === "function") {
ref(iframe);
} else if (ref) {
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
}
assignIframeRef(ref, iframe);
player.setAttribute("shader-capture-scale", "1");
player.setAttribute("shader-loading", "player");
player.setAttribute("width", String(portrait ? 1080 : 1920));
player.setAttribute("height", String(portrait ? 1920 : 1080));
player.style.width = "100%";
player.style.height = "100%";
player.style.display = "block";
player.style.background = "transparent";
loadStartedAtRef.current = performance.now();
loadIdRef.current += 1;
player.setAttribute("src", src);
container.appendChild(player);
loadStartedAtRef.current = connectPreviewPlayer(container, player, src, booted, portrait);
// Inject pasteboard shadow: let the shadow around the canvas bleed
// into the surrounding pasteboard area (overflow: visible on the container)
@@ -343,13 +346,12 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
enableInteractiveIframe(player);
for (const event of booted?.events ?? [])
handlers[event.type as PreviewPlayerEventType](event);
cleanup = () => {
iframe.removeEventListener("load", handleLoad);
stopListening();
player.removeEventListener("click", preventToggle, { capture: true });
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
player.removeEventListener("ready", handleReady);
player.removeEventListener("painted", handlePainted);
player.removeEventListener("error", handleError);
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
// `remove()` rather than `container.removeChild(player)`: by the time
@@ -12,7 +12,8 @@ vi.mock("@hyperframes/player", () => {
return {};
});
import { loadPlayerModule } from "./Player";
import { loadPlayerModule } from "../lib/previewPlayerElement";
import "./Player";
describe("loadPlayerModule", () => {
it("kicks the import at module scope, before any mount", () => {
@@ -0,0 +1,106 @@
import { parseProjectIdFromHash } from "../../utils/projectRouting";
import {
createPreviewPlayer,
previewSrc,
type HyperframesPlayerElement,
} from "./previewPlayerElement";
/** Player lifecycle events a Player replays when it adopts the boot preview (`load` fires on the iframe). */
const PREVIEW_PLAYER_EVENTS = [
"load",
"ready",
"painted",
"error",
"shadertransitionstate",
] as const;
export type PreviewPlayerEventType = (typeof PREVIEW_PLAYER_EVENTS)[number];
export interface BootPreview {
player: HyperframesPlayerElement;
/** Lifecycle events the player fired before it was adopted, in order. */
events: Event[];
startedAt: number;
}
interface ParkedPreview extends BootPreview {
src: string;
parking: HTMLElement;
stopRecording: () => void;
}
type MovableParent = HTMLElement & { moveBefore(node: Node, child: Node | null): void };
let parked: ParkedPreview | null = null;
/** Listens to the player lifecycle events; returns the unsubscribe. */
export function listenPreviewPlayer(
player: HyperframesPlayerElement,
handle: (type: PreviewPlayerEventType, event: Event) => void,
): () => void {
const listeners = PREVIEW_PLAYER_EVENTS.map((type) => {
const target: EventTarget = type === "load" ? player.iframeElement : player;
const listener = (event: Event) => handle(type, event);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
});
return () => listeners.forEach((stop) => stop());
}
/**
* Starts the preview of the project named in the URL from the page's first script, before
* Studio's app code loads; the Player that mounts for it adopts the running element.
* Needs Element.moveBefore (moving a loaded iframe any other way reloads it).
*/
export function startBootPreview(hash: string): boolean {
const projectId = parseProjectIdFromHash(hash);
const src = projectId ? previewSrc(projectId) : null;
if (parked || !src || !("moveBefore" in Element.prototype)) return false;
const player = createPreviewPlayer();
const events: Event[] = [];
const stopRecording = listenPreviewPlayer(player, (_, event) => events.push(event));
player.setAttribute("src", src);
const parking = document.createElement("div");
parking.setAttribute("aria-hidden", "true");
parking.style.cssText = "position:fixed;inset:0;opacity:0;pointer-events:none;z-index:-1";
parking.append(player);
document.body.append(parking);
parked = {
player,
events,
startedAt: performance.now(),
src,
parking,
stopRecording,
};
return true;
}
/**
* Moves the boot preview into `container` when the caller loads the same `src`, keeping its
* document. A caller with another `src` leaves it parked for the Player that does load it.
*/
export function adoptBootPreview(container: HTMLElement, src: string): BootPreview | null {
const boot = parked;
if (!boot || boot.src !== src) return null;
parked = null;
boot.stopRecording();
// Moved out before the parking is removed: disconnecting the player would tear it down.
(container as MovableParent).moveBefore(boot.player, null);
boot.parking.remove();
return boot;
}
/**
* Drops a boot preview nobody adopted: the URL moved to another project, or Studio's preview
* booted from another document.
*/
export function dropBootPreview(): void {
const boot = parked;
if (!boot) return;
parked = null;
boot.stopRecording();
boot.parking.remove();
}
@@ -0,0 +1,47 @@
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { buildProjectApiPath } from "../../utils/projectRouting";
export interface HyperframesPlayerElement extends HTMLElement {
iframeElement: HTMLIFrameElement;
}
// Importing "@hyperframes/player" registers a class extending HTMLElement at
// module load, which throws under SSR, hence the dynamic import. Clearing the memo
// on rejection stops one failure poisoning every later mount; the browser's
// module map still caches a failed fetch, so recovery is a page reload.
let playerModule: Promise<unknown> | null = null;
export function loadPlayerModule(): Promise<unknown> {
playerModule ??= import("@hyperframes/player").catch((err: unknown) => {
playerModule = null;
throw err;
});
return playerModule;
}
/** The preview document URL (path + query) a Player loads for a project or a direct URL. */
export function previewSrc(projectId?: string, directUrl?: string): string | null {
const source = directUrl || (projectId ? buildProjectApiPath(projectId, "/preview") : null);
if (!source) return null;
const url = new URL(source, window.location.origin);
applyPreviewVariablesToUrl(url);
return url.pathname + url.search;
}
/** A configured, unconnected preview player. The caller sets `src` once its listeners are on. */
export function createPreviewPlayer(portrait?: boolean): HyperframesPlayerElement {
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
player.setAttribute("shader-capture-scale", "1");
player.setAttribute("shader-loading", "player");
setPreviewPlayerOrientation(player, portrait);
player.style.width = "100%";
player.style.height = "100%";
player.style.display = "block";
player.style.background = "transparent";
return player;
}
export function setPreviewPlayerOrientation(player: HTMLElement, portrait?: boolean): void {
player.setAttribute("width", String(portrait ? 1080 : 1920));
player.setAttribute("height", String(portrait ? 1920 : 1080));
}
@@ -19,4 +19,19 @@ describe("prefetchPreviewForHash", () => {
"/api/projects/other/preview",
]);
});
it("remembers a preview the page already requested and warms it again after another project", () => {
const fetchStub = vi.fn(() => Promise.resolve(new Response("")));
vi.stubGlobal("fetch", fetchStub);
prefetchPreviewForHash("#project/opening", true);
prefetchPreviewForHash("#project/opening?tab=design");
prefetchPreviewForHash("#project/second");
prefetchPreviewForHash("#project/opening");
expect(fetchStub.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
"/api/projects/second/preview",
"/api/projects/opening/preview",
]);
});
});
+4 -2
View File
@@ -4,11 +4,13 @@ import { buildProjectApiPath, parseProjectIdFromHash } from "./projectRouting";
let prefetched: string | null = null;
/** Asks the server for the preview document of the project in the URL while Studio boots, so
* the build runs in parallel with the app instead of after the player mounts. */
export function prefetchPreviewForHash(hash: string): void {
* the build runs in parallel with the app instead of after the player mounts. `requested`: the
* page already asked for it (the boot preview), so it is only remembered. */
export function prefetchPreviewForHash(hash: string, requested = false): void {
const projectId = parseProjectIdFromHash(hash);
if (!projectId || projectId === prefetched) return;
prefetched = projectId;
if (requested) return;
const url = new URL(buildProjectApiPath(projectId, "/preview"), window.location.origin);
applyPreviewVariablesToUrl(url);
// ponytail: the response is dropped; the server keeps the built document for the player's request.