From d1cee170076bae756fda0680da334fa05c59d21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 23 Sep 2026 00:58:44 -0400 Subject: [PATCH] perf(core): let a preview play once its opening scenes attach The runtime reported ready only after every external scene had loaded. It now requests scenes in playback order and publishes __playReady (the player's ready and first-frame gate) once the scenes starting in the first 2 s are attached; later scenes nest into the root timeline as they arrive. __renderReady still waits for every scene, so renders and thumbnails are unchanged. --- packages/core/src/compositionReadiness.ts | 7 +- .../src/runtime/compositionLoader.test.ts | 31 +++ .../core/src/runtime/compositionLoader.ts | 191 +++++++++--------- packages/core/src/runtime/init.test.ts | 44 ++++ packages/core/src/runtime/init.ts | 42 +++- packages/core/src/runtime/window.d.ts | 2 + 6 files changed, 219 insertions(+), 98 deletions(-) diff --git a/packages/core/src/compositionReadiness.ts b/packages/core/src/compositionReadiness.ts index f5b22eb6da..24e189a511 100644 --- a/packages/core/src/compositionReadiness.ts +++ b/packages/core/src/compositionReadiness.ts @@ -170,6 +170,7 @@ const RENDER_READY_POLL_MS = 50; // `__hf` is omitted from Window so the runtime program's strict shape can't conflict with `unknown`. interface RuntimeReadinessWindow extends Omit { __renderReady?: boolean; + __playReady?: boolean; // __renderReady is only ever set by init.ts, which always sets __hf // first (`window.__hf = window.__hf || {}`) — a doc with no __hf can // never get __renderReady either, so this alone is enough to gate on @@ -184,7 +185,9 @@ interface RuntimeReadinessWindow extends Omit { export function computeReadinessInput(doc: Document, signal: AbortSignal): Promise | null { const win = doc.defaultView as RuntimeReadinessWindow | null; if (!win) return null; - if (win.__renderReady) return null; + // A preview can play once its playable scenes are attached; renders keep waiting on __renderReady. + const computeReady = () => win.__renderReady === true || win.__playReady === true; + if (computeReady()) return null; if (!win.__hf) return null; return new Promise((resolve) => { let timeoutId: ReturnType | undefined; @@ -199,7 +202,7 @@ export function computeReadinessInput(doc: Document, signal: AbortSignal): Promi } signal.addEventListener("abort", finish, { once: true }); const poll = () => { - if (win.__renderReady) { + if (computeReady()) { finish(); return; } diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index d85ae60d9f..101aeb1ab2 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -36,6 +36,37 @@ describe("loadExternalCompositions", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it("requests scenes in playback order and reports each one as it attaches", async () => { + for (const [id, start] of [ + ["third", 10], + ["first", 0], + ["second", 5], + ] as const) { + const host = document.createElement("div"); + host.setAttribute("data-composition-src", `https://example.com/${id}.html`); + host.setAttribute("data-composition-id", id); + host.setAttribute("data-start", String(start)); + document.body.appendChild(host); + } + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input) => new Response(`
${String(input)}
`)); + const attached: string[] = []; + + await loadExternalCompositions({ + ...defaultParams, + startOf: (host) => Number(host.getAttribute("data-start")), + onAttached: (host) => attached.push(host.getAttribute("data-composition-id") ?? ""), + }); + + expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([ + "https://example.com/first.html", + "https://example.com/second.html", + "https://example.com/third.html", + ]); + expect(attached.sort()).toEqual(["first", "second", "third"]); + }); + it("fetches and mounts external composition HTML", async () => { const host = document.createElement("div"); host.setAttribute("data-composition-src", "https://example.com/comp.html"); diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index fbc951129f..b20f9de8bf 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -13,6 +13,10 @@ import { import { isElementNode, isHtmlElement, isLinkElement, isStyleElement } from "./domRealm"; type LoadExternalCompositionsParams = { + /** Start time of a scene host; scenes are requested in this order. */ + startOf?: (host: Element) => number; + /** Called as each scene finishes loading (or fails), before the whole set settles. */ + onAttached?: (host: Element) => void; injectedStyles: HTMLStyleElement[]; injectedScripts: HTMLScriptElement[]; injectedLinks: HTMLLinkElement[]; @@ -641,111 +645,114 @@ export async function loadExternalCompositions( const hosts = trackedHosts.filter((host) => host.hasAttribute("data-composition-src")); if (hosts.length === 0) return; - await Promise.all( - hosts.map(async (host) => { - const src = host.getAttribute("data-composition-src"); - if (!src) return; - const hostIdentity = hostIdentityByElement.get(host); - const authoredCompositionId = hostIdentity?.authoredCompositionId || null; - const runtimeCompositionId = - hostIdentity?.runtimeCompositionId || authoredCompositionId || null; - let compositionUrl: URL | null = null; - try { - compositionUrl = new URL(src, document.baseURI); - } catch { - compositionUrl = null; - } - resetCompositionHost(host); - try { - const localTemplate = - authoredCompositionId != null - ? document.querySelector( - `template#${CSS.escape(authoredCompositionId)}-template`, - ) - : null; - if (localTemplate) { - await mountCompositionContent({ - host, - authoredCompositionId, - runtimeCompositionId, - hostCompositionSrc: src, - sourceNode: localTemplate.content, - hasTemplate: true, - fallbackBodyInnerHtml: "", - compositionUrl, - injectedStyles: params.injectedStyles, - injectedScripts: params.injectedScripts, - injectedLinks: params.injectedLinks, - parseDimensionPx: params.parseDimensionPx, - onDiagnostic: params.onDiagnostic, - }); - return; - } - const response = await fetch(src); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const html = await response.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - // Rewrite project-root-traversing (`../`) asset paths against the - // sub-composition's URL before extracting any nodes. Without this, - // `