diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index c325118995..4aca573e7c 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -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(); diff --git a/packages/studio/index.html b/packages/studio/index.html index b3659e0763..b6668d5731 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -8,6 +8,6 @@
- + diff --git a/packages/studio/src/boot.ts b/packages/studio/src/boot.ts new file mode 100644 index 0000000000..1ea89ac23a --- /dev/null +++ b/packages/studio/src/boot.ts @@ -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"); diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx index 17d97def97..cade2949e6 100644 --- a/packages/studio/src/main.tsx +++ b/packages/studio/src/main.tsx @@ -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; diff --git a/packages/studio/src/player/components/Player.test.ts b/packages/studio/src/player/components/Player.test.ts index 57609013c3..bc8c124fb5 100644 --- a/packages/studio/src/player/components/Player.test.ts +++ b/packages/studio/src/player/components/Player.test.ts @@ -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("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); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index bd94b7f86c..86767409b3 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -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 | null = null; - -export function loadPlayerModule(): Promise { - 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, 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( 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( 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( // 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 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).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( 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 diff --git a/packages/studio/src/player/components/playerModuleRetry.test.ts b/packages/studio/src/player/components/playerModuleRetry.test.ts index 273fd81fad..29f1874fde 100644 --- a/packages/studio/src/player/components/playerModuleRetry.test.ts +++ b/packages/studio/src/player/components/playerModuleRetry.test.ts @@ -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", () => { diff --git a/packages/studio/src/player/lib/bootPreview.ts b/packages/studio/src/player/lib/bootPreview.ts new file mode 100644 index 0000000000..5b1c4b6de1 --- /dev/null +++ b/packages/studio/src/player/lib/bootPreview.ts @@ -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(); +} diff --git a/packages/studio/src/player/lib/previewPlayerElement.ts b/packages/studio/src/player/lib/previewPlayerElement.ts new file mode 100644 index 0000000000..76cc6ea88e --- /dev/null +++ b/packages/studio/src/player/lib/previewPlayerElement.ts @@ -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 | null = null; + +export function loadPlayerModule(): Promise { + 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)); +} diff --git a/packages/studio/src/utils/previewPrefetch.test.ts b/packages/studio/src/utils/previewPrefetch.test.ts index 5698d2ec9d..536f89c03b 100644 --- a/packages/studio/src/utils/previewPrefetch.test.ts +++ b/packages/studio/src/utils/previewPrefetch.test.ts @@ -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", + ]); + }); }); diff --git a/packages/studio/src/utils/previewPrefetch.ts b/packages/studio/src/utils/previewPrefetch.ts index 09854e4734..2c7baf613f 100644 --- a/packages/studio/src/utils/previewPrefetch.ts +++ b/packages/studio/src/utils/previewPrefetch.ts @@ -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.