mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): composition scripts start after web fonts are ready, so renders repeat (#4980)
* fix(core): composition scripts start after web fonts are ready A scene script that measures text (offsetHeight, getBoundingClientRect) ran while the page parsed, often before its web font loaded, so it laid the scene out with fallback metrics. Renders of the same film differed run to run, and preview could differ from export. The compilers now give every body script a type the browser does not run (text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called at the end of the bundler, the producer compile and the sub-composition preview. The Studio motion script is emitted the same way so it still runs after them. The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script in its own place in parser order (classic in order, awaiting src loads; then module and defer scripts in insertion order), boots as before, and calls the DOMContentLoaded and load listeners the scripts added. On timeout the scripts run anyway and runtime_font_wait_timeout names the families still loading. Scene swaps and the composition loader use the same wait. * fix(core): a page whose runtime predates the font gate still runs its scripts A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that runtime is older than the font gate, nothing would run the deferred composition scripts and the film would be blank. Whenever the marking helper defers scripts, it also puts one small inline script first in the head. At DOMContentLoaded it runs the deferred scripts in parser order itself, without waiting for fonts and with a console warning, unless a runtime has claimed them. The gate's runtime claims them when it loads, before DOMContentLoaded, so the scripts run exactly once either way. * fix(studio-server): motion paths animate in Studio preview when gsap loads in the body The MotionPathPlugin tag Studio inserts after a body gsap script now carries that script's type, so the runtime runs it right after gsap once fonts are ready, instead of before gsap exists. * fix(cli): layout, motion-shot and validate wait for the composition scripts to run The three commands waited on the timeline registry, which exists as soon as the runtime file loads, or on a flat sleep. They now wait for the runtime's render-ready flag through one shared helper, keeping each command's timeout and proceeding as before when it passes. * fix(core): a script that wraps addEventListener keeps its wrapper after its scripts run While composition scripts run, the runtime patches addEventListener to hold load events that already passed. Putting the original back now happens only if the patch is still in place, so a library wrapper installed meanwhile survives. * docs: comments on script timing match scripts that wait for web fonts The early stub's flush and the player's iframe-load handler described page scripts as done by DOMContentLoaded and the runtime as ready before load. Composition scripts now wait for web fonts, so both can come later. Comment-only. * test(core): pin script order after an external script and the scene swap's font wait An inline composition script that follows an external one sees its global, and a scene swap runs the new scene's script only once web fonts are ready. * docs(producer): the early stub comment names what drains late font-gated work * fix(studio): motion paths add without a flash when gsap loads after web fonts Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready. * test(cli): pin the runtime wait before each command first samples the page * fix(studio): the motion path preload stops waiting once the preview runtime has booted A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter. * fix(studio): a preview frame that never boots is not kept alive by the motion path preload Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected. * style: comments added by the font gate move into names
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"packages/aws-lambda": {
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"bin": {
|
||||
"hyperframes": "./bin/hyperframes.mjs",
|
||||
"hyperframes-localize-fonts": "./bin/hyperframes-localize-fonts.mjs",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@hyperframes/lint": "workspace:*",
|
||||
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -154,7 +154,7 @@
|
||||
},
|
||||
"packages/gcp-cloud-run": {
|
||||
"name": "@hyperframes/gcp-cloud-run",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@google-cloud/workflows": "^4.2.0",
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/lint": {
|
||||
"name": "@hyperframes/lint",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"acorn": "^8.17.0",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/parsers": {
|
||||
"name": "@hyperframes/parsers",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"acorn": "^8.17.0",
|
||||
@@ -215,7 +215,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
},
|
||||
@@ -230,7 +230,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -275,7 +275,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@hyperframes/sdk",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
@@ -305,7 +305,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -317,7 +317,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -370,7 +370,7 @@
|
||||
},
|
||||
"packages/studio-server": {
|
||||
"name": "@hyperframes/studio-server",
|
||||
"version": "0.8.111",
|
||||
"version": "0.8.119",
|
||||
"dependencies": {
|
||||
"@hyperframes/core": "workspace:*",
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUDIT_SEEK_OPTIONS,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
resolveCropRegion,
|
||||
runFfmpegOnce,
|
||||
seekCompositionTimeline,
|
||||
waitForRuntimeReady,
|
||||
type CompositionSeekPage,
|
||||
} from "./captureCompositionFrame.js";
|
||||
|
||||
@@ -48,6 +50,39 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("waitForRuntimeReady", () => {
|
||||
it("waits for the runtime's render-ready flag and reports a timeout as not ready", async () => {
|
||||
const { waitForFunction } = fakeSeekPage();
|
||||
|
||||
await expect(waitForRuntimeReady({ waitForFunction }, 123)).resolves.toBe(true);
|
||||
expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 123 });
|
||||
const ready = waitForFunction.mock.calls[0]![0];
|
||||
vi.stubGlobal("window", { __timelines: {} });
|
||||
expect(ready()).toBe(false);
|
||||
vi.stubGlobal("window", { __timelines: {}, __renderReady: true });
|
||||
expect(ready()).toBe(true);
|
||||
|
||||
waitForFunction.mockRejectedValueOnce(new Error("Waiting failed: 1ms exceeded"));
|
||||
await expect(waitForRuntimeReady({ waitForFunction }, 1)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("is what layout, motion-shot and validate wait on before they sample the page", () => {
|
||||
const commands = join(dirname(fileURLToPath(import.meta.url)), "../commands");
|
||||
const firstSample = {
|
||||
layout: "getCompositionDuration(page)",
|
||||
motionShot: "installSeekHelper(page)",
|
||||
validate: "auditClipDurations(page,",
|
||||
};
|
||||
for (const [name, sample] of Object.entries(firstSample)) {
|
||||
const source = readFileSync(join(commands, `${name}.ts`), "utf8");
|
||||
const wait = source.indexOf("await waitForRuntimeReady(page, ");
|
||||
expect(wait, name).toBeGreaterThan(-1);
|
||||
expect(source.indexOf(sample), name).toBeGreaterThan(wait);
|
||||
expect(source, name).not.toContain("__timelines?: unknown }).__timelines");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("seekCompositionTimeline", () => {
|
||||
it("keeps the raced double-frame settle and adds a bounded font wait by default", async () => {
|
||||
const { page, evaluate, waitForFunction } = fakeSeekPage();
|
||||
|
||||
@@ -125,14 +125,21 @@ function shaderTransitionsReadyInBrowser(): boolean {
|
||||
return shaderTransitionRegistryReady() ?? shaderLoadingOverlayReady();
|
||||
}
|
||||
|
||||
export function waitForRuntimeReady(
|
||||
page: Required<Pick<CompositionSeekPage, "waitForFunction">>,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
return page
|
||||
.waitForFunction(compositionRuntimeReadyInBrowser, { timeout: timeoutMs })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function waitForCompositionSettle(
|
||||
page: Page,
|
||||
options: OpenSettledCompositionPageOptions,
|
||||
): Promise<boolean> {
|
||||
const runtimeReady = await page
|
||||
.waitForFunction(compositionRuntimeReadyInBrowser, { timeout: options.renderReadyTimeoutMs })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const runtimeReady = await waitForRuntimeReady(page, options.renderReadyTimeoutMs);
|
||||
|
||||
if (!runtimeReady) {
|
||||
console.warn(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
installPageFunctionGuard,
|
||||
seekCompositionTimeline,
|
||||
waitForCompositionFonts,
|
||||
waitForRuntimeReady,
|
||||
type SeekCompositionTimelineOptions,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
|
||||
@@ -237,11 +238,7 @@ async function runLayoutAudit(
|
||||
});
|
||||
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
|
||||
await alignViewportToComposition(page, server.url);
|
||||
await page
|
||||
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
|
||||
timeout: opts.timeout,
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitForRuntimeReady(page, opts.timeout);
|
||||
await waitForCompositionFonts(page, 750);
|
||||
await new Promise((resolveSettle) => setTimeout(resolveSettle, 250));
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { waitForRuntimeReady } from "../capture/captureCompositionFrame.js";
|
||||
import {
|
||||
assertWebGpuAdapterAvailable,
|
||||
compositionRequiresWebGpu,
|
||||
@@ -415,11 +416,7 @@ async function openCompositionPage(
|
||||
await page.setViewport(size);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
|
||||
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
|
||||
await page
|
||||
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitForRuntimeReady(page, 10000);
|
||||
await page
|
||||
.evaluate(async () => {
|
||||
const d = document as unknown as { fonts?: { ready?: Promise<unknown> } };
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
installPageFunctionGuard,
|
||||
resolveCliChromeGpuMode,
|
||||
seekCompositionTimeline,
|
||||
waitForRuntimeReady,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -503,6 +504,7 @@ async function validateInBrowser(
|
||||
throw err;
|
||||
}
|
||||
await assertWebGpuAdapterAvailable(page, requiresWebGpu);
|
||||
await waitForRuntimeReady(page, opts.timeout ?? 3000);
|
||||
await new Promise((r) => setTimeout(r, opts.timeout ?? 3000));
|
||||
|
||||
for (const w of await auditClipDurations(page, analyzeClipMediaFit, opts.timeout ?? 3000)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resetUnknownEnumWarnings } from "../runtime/getVariables";
|
||||
import { sanitizeCssValue } from "../runtime/applyVariableBindings";
|
||||
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
|
||||
import { ensureHfIds } from "../parsers/hfIds";
|
||||
import { AFTER_FONTS_SCRIPT_TYPE, AFTER_FONTS_SCRIPTS } from "./scriptRuns";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
|
||||
@@ -215,6 +216,33 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).toContain('document.getElementById("scene")');
|
||||
});
|
||||
|
||||
it("defers the root's and every mounted composition's scripts until fonts, but not the runtime", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180">
|
||||
<div data-composition-id="scene" data-composition-src="compositions/scene.html"></div>
|
||||
</div>
|
||||
<script>window.ROOT_SCRIPT = 1;</script>
|
||||
<script type="module">window.ROOT_MODULE = 1;</script>
|
||||
</body></html>`,
|
||||
"compositions/scene.html": `<template><div data-composition-id="scene" data-width="320" data-height="180">
|
||||
<script>window.SCENE_SCRIPT = 1;</script>
|
||||
</div></template>`,
|
||||
});
|
||||
|
||||
const { document } = parseHTML(await bundleToSingleHtml(dir));
|
||||
const deferred = [...document.querySelectorAll(AFTER_FONTS_SCRIPTS)].map(
|
||||
(el) => el.textContent,
|
||||
);
|
||||
for (const mark of ["SCENE_SCRIPT", "ROOT_SCRIPT", "ROOT_MODULE"]) {
|
||||
expect(deferred.some((text) => text?.includes(mark))).toBe(true);
|
||||
}
|
||||
expect([...document.querySelectorAll("body script")].length).toBe(deferred.length);
|
||||
const runtime = document.querySelector("script[data-hyperframes-preview-runtime]");
|
||||
expect(runtime?.getAttribute("type")).toBeNull();
|
||||
});
|
||||
|
||||
it("binds a mounted composition's scripts to its own file for __hyperframes.assetUrl", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
@@ -257,8 +285,13 @@ describe("bundleToSingleHtml", () => {
|
||||
|
||||
const { document } = parseHTML(await bundleToSingleHtml(dir));
|
||||
const importMaps = [...document.querySelectorAll('script[type="importmap"]')];
|
||||
const modules = [...document.querySelectorAll('script[type="module"]')];
|
||||
const classic = [...document.querySelectorAll("script:not([type])")].map((s) => s.textContent);
|
||||
// Deferred until fonts: the runtime runs each as the type its marker names.
|
||||
const modules = [
|
||||
...document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}+module"]`),
|
||||
];
|
||||
const classic = [...document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}"]`)].map(
|
||||
(s) => s.textContent,
|
||||
);
|
||||
|
||||
expect(importMaps).toHaveLength(1);
|
||||
expect(JSON.parse(importMaps[0]!.textContent || "")).toEqual({
|
||||
@@ -599,7 +632,9 @@ describe("bundleToSingleHtml", () => {
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
expect(bundled).toMatch(/<script\b[^>]*\btype="module"[^>]*\bsrc="\.\/module\.js"/);
|
||||
expect(bundled).toMatch(
|
||||
/<script\b[^>]*\btype="text\/hf-after-fonts\+module"[^>]*\bsrc="\.\/module\.js"/,
|
||||
);
|
||||
expect(bundled).not.toContain('import { value } from "./value.js"');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
compositionStyle,
|
||||
cssStyleMergeKey,
|
||||
deferScriptsUntilFonts,
|
||||
UNCONDITIONAL_CSS_KEY,
|
||||
headStyleRuns,
|
||||
inlineScriptRuns,
|
||||
@@ -1293,6 +1294,7 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
enforceCompositionPixelSizing(document);
|
||||
autoHealMissingCompositionIds(document);
|
||||
coalesceHeadStylesAndBodyScripts(document);
|
||||
deferScriptsUntilFonts(document, (el) => el.hasAttribute(RUNTIME_BOOTSTRAP_ATTR));
|
||||
injectTextRenderingRule(document);
|
||||
|
||||
// Inline textual assets
|
||||
|
||||
@@ -61,8 +61,10 @@ export { addScenePartsManifest } from "./scenePartsManifest";
|
||||
|
||||
// Script ordering shared by the bundler and the producer coalescers
|
||||
export {
|
||||
AFTER_FONTS_SCRIPT_TYPE,
|
||||
compositionStyle,
|
||||
cssStyleMergeKey,
|
||||
deferScriptsUntilFonts,
|
||||
headStyleRuns,
|
||||
inlineScriptRuns,
|
||||
styleElementsFor,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// @vitest-environment node
|
||||
import { parseHTML } from "linkedom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inlineScriptRuns } from "./scriptRuns";
|
||||
import {
|
||||
AFTER_FONTS_SCRIPT_TYPE,
|
||||
deferScriptsUntilFonts,
|
||||
inlineScriptRuns,
|
||||
typeAfterFonts,
|
||||
} from "./scriptRuns";
|
||||
|
||||
function runsOf(bodyHtml: string, isPinned?: (el: Element) => boolean) {
|
||||
const { document } = parseHTML(`<!doctype html><html><body>${bodyHtml}</body></html>`);
|
||||
@@ -50,3 +55,40 @@ describe("inlineScriptRuns", () => {
|
||||
expect(runsOf('<script src="x.js"></script>')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deferScriptsUntilFonts", () => {
|
||||
it("defers each classic and module body script, and only those", () => {
|
||||
const { document } = parseHTML(
|
||||
`<!doctype html><html><head><script>head</script></head><body>` +
|
||||
`<script>a</script><script src="lib.js"></script><script type="text/javascript">b</script>` +
|
||||
`<script type="module">m</script><script type="application/json">{}</script>` +
|
||||
`<script type="importmap">{}</script><script data-runtime>r</script>` +
|
||||
`<svg><script>s</script></svg><noscript><script>n</script></noscript></body></html>`,
|
||||
);
|
||||
deferScriptsUntilFonts(document as unknown as Document, (el) =>
|
||||
el.hasAttribute("data-runtime"),
|
||||
);
|
||||
const deferred = AFTER_FONTS_SCRIPT_TYPE;
|
||||
// First in the head: the fallback that runs them under a runtime without the gate.
|
||||
expect(document.head.firstElementChild?.textContent).toContain("no web-font gate");
|
||||
expect([...document.querySelectorAll("script")].map((el) => el.getAttribute("type"))).toEqual([
|
||||
null,
|
||||
null,
|
||||
deferred,
|
||||
deferred,
|
||||
deferred,
|
||||
`${deferred}+module`,
|
||||
"application/json",
|
||||
"importmap",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect([...document.querySelectorAll("body script")].slice(0, 4).map(typeAfterFonts)).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"module",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,70 @@ export function inlineScriptRuns(
|
||||
return runs;
|
||||
}
|
||||
|
||||
export const AFTER_FONTS_SCRIPT_TYPE = "text/hf-after-fonts";
|
||||
const AFTER_FONTS_MODULE_TYPE = `${AFTER_FONTS_SCRIPT_TYPE}+module`;
|
||||
export const AFTER_FONTS_SCRIPTS = `script[type="${AFTER_FONTS_SCRIPT_TYPE}"], script[type="${AFTER_FONTS_MODULE_TYPE}"]`;
|
||||
|
||||
export const AFTER_FONTS_CLAIM = "__hfAfterFontsClaimed";
|
||||
const AFTER_FONTS_FALLBACK_ATTR = "data-hf-after-fonts-fallback";
|
||||
|
||||
// For a runtime older than the gate: at DOMContentLoaded, before that runtime boots, run them in parser order.
|
||||
const afterFontsFallback = () => `document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.${AFTER_FONTS_CLAIM}) return;
|
||||
var T = "${AFTER_FONTS_SCRIPT_TYPE}";
|
||||
var all = [].slice.call(document.querySelectorAll('${AFTER_FONTS_SCRIPTS}'));
|
||||
if (!all.length) return;
|
||||
console.warn("[hyperframes] the runtime has no web-font gate; composition scripts run without waiting for fonts");
|
||||
var late = function (el) { return el.type !== T || (el.hasAttribute("src") && el.hasAttribute("defer")); };
|
||||
var queue = all.filter(function (el) { return !late(el); }).concat(all.filter(late));
|
||||
(function next() {
|
||||
var el = queue.shift();
|
||||
if (!el) return;
|
||||
var s = document.createElement("script");
|
||||
for (var i = 0; i < el.attributes.length; i++) s.setAttribute(el.attributes[i].name, el.attributes[i].value);
|
||||
if (el.type === T) s.removeAttribute("type"); else s.type = "module";
|
||||
s.async = el.hasAttribute("async");
|
||||
s.text = el.text;
|
||||
var waits = s.type !== "module" && s.hasAttribute("src") && !s.async && !s.noModule;
|
||||
if (waits) { s.addEventListener("load", next); s.addEventListener("error", next); }
|
||||
el.replaceWith(s);
|
||||
if (!waits) next();
|
||||
})();
|
||||
});`;
|
||||
|
||||
/** Gives each body script a type the browser does not run, so the runtime can run it once web fonts are ready. */
|
||||
export function deferScriptsUntilFonts(
|
||||
document: Document,
|
||||
isFramework: (el: Element) => boolean = () => false,
|
||||
): void {
|
||||
let deferred = false;
|
||||
for (const el of document.querySelectorAll("body script")) {
|
||||
if (isFramework(el) || el.closest("noscript, svg")) continue;
|
||||
if (isClassicInline(el)) el.setAttribute("type", AFTER_FONTS_SCRIPT_TYPE);
|
||||
else if ((el.getAttribute("type") || "").trim().toLowerCase() === "module") {
|
||||
el.setAttribute("type", AFTER_FONTS_MODULE_TYPE);
|
||||
} else continue;
|
||||
deferred = true;
|
||||
}
|
||||
if (
|
||||
!deferred ||
|
||||
!document.head ||
|
||||
document.querySelector(`script[${AFTER_FONTS_FALLBACK_ATTR}]`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const fallback = document.createElement("script");
|
||||
fallback.setAttribute(AFTER_FONTS_FALLBACK_ATTR, "");
|
||||
fallback.textContent = afterFontsFallback();
|
||||
document.head.insertBefore(fallback, document.head.firstChild);
|
||||
}
|
||||
|
||||
export function typeAfterFonts(el: Element): string | null {
|
||||
const type = el.getAttribute("type");
|
||||
if (type === AFTER_FONTS_SCRIPT_TYPE) return null;
|
||||
return type === AFTER_FONTS_MODULE_TYPE ? "module" : type;
|
||||
}
|
||||
|
||||
/** Undefined for a type the browser never applies as CSS; `media="all"` and an empty title count as none. */
|
||||
export function cssStyleMergeKey(el: Element): string | undefined {
|
||||
const rawType = el.getAttribute("type") ?? "";
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { AFTER_FONTS_CLAIM, typeAfterFonts } from "../compiler/scriptRuns";
|
||||
import { postRuntimeMessage } from "./bridge";
|
||||
|
||||
/** Past the 3 s a font-display:block face holds text back, far under the engine's 45 s player-ready wait. */
|
||||
export const FONT_WAIT_TIMEOUT_MS = 5000;
|
||||
|
||||
(window as unknown as Record<string, unknown>)[AFTER_FONTS_CLAIM] = true;
|
||||
|
||||
/** Resolves once the page's web fonts are ready or the timeout passes, reporting faces still loading. */
|
||||
export async function waitForFonts(): Promise<void> {
|
||||
const fonts = document.fonts;
|
||||
if (!fonts) return;
|
||||
// Layout starts loading the faces laid-out text uses; with none loading, ready resolves at once.
|
||||
void document.body?.offsetHeight;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
fonts.ready.then(() => false),
|
||||
new Promise<boolean>((resolve) => (timer = setTimeout(resolve, FONT_WAIT_TIMEOUT_MS, true))),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (!timedOut) return;
|
||||
const loadingFamilies = [
|
||||
...new Set(
|
||||
Array.from(fonts)
|
||||
.filter((face) => face.status === "loading")
|
||||
.map((face) => face.family),
|
||||
),
|
||||
];
|
||||
console.warn(
|
||||
`[hyperframes] fonts still loading after ${FONT_WAIT_TIMEOUT_MS} ms, composition scripts run without them: ${loadingFamilies.join(", ")}`,
|
||||
);
|
||||
postRuntimeMessage({
|
||||
source: "hf-preview",
|
||||
type: "diagnostic",
|
||||
code: "runtime_font_wait_timeout",
|
||||
details: { loadingFamilies, timeoutMs: FONT_WAIT_TIMEOUT_MS },
|
||||
});
|
||||
}
|
||||
|
||||
type HeldListener = {
|
||||
target: EventTarget;
|
||||
type: string;
|
||||
listener: EventListenerOrEventListenerObject;
|
||||
};
|
||||
|
||||
/** Queues DOMContentLoaded listeners, and load listeners once the page has loaded: both events have passed. */
|
||||
function holdPassedLoadEvents(target: Document | Window, held: HeldListener[]): () => void {
|
||||
const own = Object.getOwnPropertyDescriptor(target, "addEventListener");
|
||||
const add = target.addEventListener;
|
||||
const hold = (
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) => {
|
||||
if (!listener) return;
|
||||
const passed =
|
||||
type === "DOMContentLoaded" ||
|
||||
(type === "load" && target === window && document.readyState === "complete");
|
||||
if (passed) held.push({ target, type, listener });
|
||||
else add.call(target, type, listener, options);
|
||||
};
|
||||
target.addEventListener = hold;
|
||||
return () => {
|
||||
// A script that wrapped it meanwhile keeps its wrapper.
|
||||
if (target.addEventListener !== hold) return;
|
||||
if (own) Object.defineProperty(target, "addEventListener", own);
|
||||
else delete (target as { addEventListener?: unknown }).addEventListener;
|
||||
};
|
||||
}
|
||||
|
||||
function runInPlace(el: Element): HTMLScriptElement | undefined {
|
||||
if (!el.isConnected) return;
|
||||
const script = document.createElement("script");
|
||||
for (const { name, value } of Array.from(el.attributes)) script.setAttribute(name, value);
|
||||
const type = typeAfterFonts(el);
|
||||
if (type === null) script.removeAttribute("type");
|
||||
else script.setAttribute("type", type);
|
||||
// Not async: inserted src and module scripts then run in insertion order.
|
||||
script.async = el.hasAttribute("async");
|
||||
script.text = el.textContent ?? "";
|
||||
el.replaceWith(script);
|
||||
return script;
|
||||
}
|
||||
|
||||
const loaded = (script: HTMLScriptElement) =>
|
||||
new Promise((resolve) => {
|
||||
script.addEventListener("load", resolve);
|
||||
script.addEventListener("error", resolve);
|
||||
});
|
||||
|
||||
// An inline module fires no load event; a module queued after the others runs once they have.
|
||||
function afterQueuedModules(): Promise<unknown> {
|
||||
const sentinel = document.createElement("script");
|
||||
sentinel.type = "module";
|
||||
sentinel.async = false;
|
||||
sentinel.text = `document.dispatchEvent(new Event("hf-after-fonts-modules"))`;
|
||||
const ran = new Promise((resolve) =>
|
||||
document.addEventListener("hf-after-fonts-modules", resolve, { once: true }),
|
||||
);
|
||||
document.body.appendChild(sentinel);
|
||||
return ran.then(() => sentinel.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Once fonts are ready, runs each script in its own place in parser order (classic, then module and
|
||||
* deferred), calls `afterRun`, then the load listeners the scripts added after those events passed.
|
||||
*/
|
||||
export async function runScriptsAfterFonts(
|
||||
scripts: readonly Element[],
|
||||
afterRun?: () => void,
|
||||
): Promise<void> {
|
||||
await waitForFonts();
|
||||
const isLate = (el: Element) =>
|
||||
typeAfterFonts(el) === "module" || (el.hasAttribute("src") && el.hasAttribute("defer"));
|
||||
const late = scripts.filter(isLate);
|
||||
const held: HeldListener[] = [];
|
||||
const release = [document, window].map((target) => holdPassedLoadEvents(target, held));
|
||||
for (const el of scripts.filter((el) => !isLate(el))) {
|
||||
const script = runInPlace(el);
|
||||
if (script?.hasAttribute("src") && !script.async && !script.noModule) await loaded(script);
|
||||
}
|
||||
for (const el of late) runInPlace(el);
|
||||
if (late.length > 0) await afterQueuedModules();
|
||||
for (const undo of release) undo();
|
||||
afterRun?.();
|
||||
for (const { target, type, listener } of held) {
|
||||
const event = new Event(type);
|
||||
try {
|
||||
if (typeof listener === "function") listener.call(target, event);
|
||||
else listener.handleEvent(event);
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ describe("loadExternalCompositions", () => {
|
||||
delete (window as Window & { __hyperframes?: unknown }).__hyperframes;
|
||||
delete (window as Window & { __timelines?: unknown }).__timelines;
|
||||
delete (window as WindowWithScopedVars).__hfVariablesByComp;
|
||||
delete (document as { fonts?: unknown }).fonts;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -503,6 +504,28 @@ describe("loadExternalCompositions", () => {
|
||||
expect(injectedScripts[0].textContent).toContain("console.log");
|
||||
});
|
||||
|
||||
it("mounts the content, then runs its scripts only once web fonts are ready", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/comp.html");
|
||||
document.body.appendChild(host);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(`<html><body><p>Text</p><script>void 0</script></body></html>`, {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
let fontsLoaded = () => {};
|
||||
const ready = new Promise<void>((resolve) => (fontsLoaded = resolve));
|
||||
Object.defineProperty(document, "fonts", { configurable: true, value: { ready } });
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
|
||||
const loading = loadExternalCompositions({ ...defaultParams, injectedScripts });
|
||||
await vi.waitFor(() => expect(host.textContent).toContain("Text"));
|
||||
expect(injectedScripts).toHaveLength(0);
|
||||
fontsLoaded();
|
||||
await loading;
|
||||
expect(injectedScripts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("scopes injected styles and document selectors to the mounted composition root", async () => {
|
||||
const otherRoot = document.createElement("div");
|
||||
otherRoot.setAttribute("data-composition-id", "other");
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../compiler/compositionScoping";
|
||||
import { parseImportMap } from "../compiler/importMaps";
|
||||
import { hasSameLink } from "../compiler/scriptRuns";
|
||||
import { waitForFonts } from "./afterFonts";
|
||||
import { parseLayoutDimension } from "./compositionDimension";
|
||||
import { markFlattenedInnerRoot } from "./flattenedRoot";
|
||||
import {
|
||||
@@ -554,6 +555,7 @@ async function mountCompositionContent(params: {
|
||||
stashInstanceVariables(params, contentNode, runtimeScopeCompositionId);
|
||||
}
|
||||
|
||||
if (scriptPayloads.length > 0) await waitForFonts();
|
||||
for (const scriptPayload of scriptPayloads) {
|
||||
const injectedScript = document.createElement("script");
|
||||
if (scriptPayload.type) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AFTER_FONTS_CLAIM,
|
||||
AFTER_FONTS_SCRIPT_TYPE,
|
||||
deferScriptsUntilFonts,
|
||||
} from "../compiler/scriptRuns";
|
||||
import { FONT_WAIT_TIMEOUT_MS } from "./afterFonts";
|
||||
|
||||
type Face = { family: string; status: FontFaceLoadStatus };
|
||||
|
||||
// jsdom runs scripts in its own global, so they report through the shared document.
|
||||
const logs = (entry: string) =>
|
||||
`document.getElementById("log").textContent += ${JSON.stringify(`${entry} `)};`;
|
||||
|
||||
function mountDeferredScripts(): void {
|
||||
document.body.innerHTML =
|
||||
`<output id="log"></output>` +
|
||||
`<div data-composition-id="main" data-root="true" data-start="0" data-width="1920" data-height="1080">` +
|
||||
`<div id="scene"><script type="${AFTER_FONTS_SCRIPT_TYPE}">${logs("a")}` +
|
||||
`document.addEventListener("DOMContentLoaded", function () { ${logs("ready")} });` +
|
||||
`if (document.currentScript.closest("#scene")) { ${logs("in-place")} }</script></div></div>` +
|
||||
`<script type="${AFTER_FONTS_SCRIPT_TYPE}">${logs("b")}</script>`;
|
||||
}
|
||||
|
||||
function serveFonts(ready: Promise<unknown>, faces: Face[]): void {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { ready, [Symbol.iterator]: () => faces[Symbol.iterator]() },
|
||||
});
|
||||
}
|
||||
|
||||
async function parseThenLoad(): Promise<void> {
|
||||
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
|
||||
vi.resetModules();
|
||||
await import("./entry");
|
||||
delete (document as { readyState?: unknown }).readyState;
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
}
|
||||
|
||||
const log = () => document.getElementById("log")!.textContent;
|
||||
const claims = (claimed: boolean) =>
|
||||
claimed
|
||||
? ((window as unknown as Record<string, unknown>)[AFTER_FONTS_CLAIM] = true)
|
||||
: delete (window as unknown as Record<string, unknown>)[AFTER_FONTS_CLAIM];
|
||||
|
||||
// A page compiled elsewhere and imported, so jsdom runs none of its scripts; the test runs the fallback.
|
||||
function compilePage(): void {
|
||||
const compiled = new DOMParser().parseFromString(
|
||||
`<output id="log"></output><script>${logs("a")}</script><script>${logs("b")}</script>`,
|
||||
"text/html",
|
||||
);
|
||||
deferScriptsUntilFonts(compiled);
|
||||
const fallback = compiled.head.querySelector("script")!;
|
||||
expect(fallback.textContent).toContain("no web-font gate");
|
||||
document.body.replaceChildren(
|
||||
...Array.from(compiled.body.childNodes, (node) => document.importNode(node, true)),
|
||||
);
|
||||
new Function(fallback.textContent!)();
|
||||
}
|
||||
|
||||
describe("runtime entry: composition scripts after web fonts", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.__hfRuntimeTeardown?.();
|
||||
document.body.innerHTML = "";
|
||||
window.__timelines = {};
|
||||
claims(true);
|
||||
delete window.__player;
|
||||
delete (window as { __hyperframeRuntimeBootstrapped?: boolean })
|
||||
.__hyperframeRuntimeBootstrapped;
|
||||
delete (document as { fonts?: unknown }).fonts;
|
||||
delete (document as { addEventListener?: unknown }).addEventListener;
|
||||
});
|
||||
|
||||
it("runs deferred scripts once fonts are ready, in order and in place, then boots and fires their DOMContentLoaded", async () => {
|
||||
let fontsLoaded = () => {};
|
||||
serveFonts(new Promise<void>((resolve) => (fontsLoaded = resolve)), []);
|
||||
mountDeferredScripts();
|
||||
|
||||
await parseThenLoad();
|
||||
await Promise.resolve();
|
||||
expect(log()).toBe("");
|
||||
expect(window.__player).toBeUndefined();
|
||||
|
||||
fontsLoaded();
|
||||
await vi.waitFor(() => expect(window.__player).toBeDefined());
|
||||
expect(log()).toBe("a in-place b ready ");
|
||||
expect(document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}"]`)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps an addEventListener wrapper a deferred script installs while its load events are held", async () => {
|
||||
serveFonts(Promise.resolve(), []);
|
||||
document.body.innerHTML =
|
||||
`<output id="log"></output>` +
|
||||
`<script type="${AFTER_FONTS_SCRIPT_TYPE}">var add = document.addEventListener;` +
|
||||
`document.addEventListener = function () { ${logs("wrapped")} return add.apply(this, arguments); };` +
|
||||
`</script>`;
|
||||
|
||||
await parseThenLoad();
|
||||
await vi.waitFor(() => expect(window.__player).toBeDefined());
|
||||
const before = log();
|
||||
document.addEventListener("hf-later", () => {});
|
||||
expect(log()).toBe(`${before}wrapped `);
|
||||
});
|
||||
|
||||
it("runs an inline script after an external one only once the external one has loaded", async () => {
|
||||
serveFonts(Promise.resolve(), []);
|
||||
// jsdom fetches no src: the test loads the library, once the runtime has put its script in place.
|
||||
const library = new MutationObserver((records) => {
|
||||
for (const node of records.flatMap((record) => [...record.addedNodes])) {
|
||||
if (!(node instanceof HTMLScriptElement) || !node.src || node.type) continue;
|
||||
document.body.dataset.lib = "loaded";
|
||||
node.dispatchEvent(new Event("load"));
|
||||
}
|
||||
});
|
||||
library.observe(document.body, { childList: true });
|
||||
document.body.innerHTML =
|
||||
`<output id="log"></output>` +
|
||||
`<script type="${AFTER_FONTS_SCRIPT_TYPE}" src="https://cdn.example/lib.js"></script>` +
|
||||
`<script type="${AFTER_FONTS_SCRIPT_TYPE}">` +
|
||||
`document.getElementById("log").textContent += "lib:" + document.body.dataset.lib;</script>`;
|
||||
|
||||
await parseThenLoad();
|
||||
await vi.waitFor(() => expect(window.__player).toBeDefined());
|
||||
library.disconnect();
|
||||
delete document.body.dataset.lib;
|
||||
expect(log()).toBe("lib:loaded");
|
||||
});
|
||||
|
||||
it("runs the scripts at the font timeout and reports the families still loading", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const posted = vi.spyOn(window.parent, "postMessage").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
serveFonts(new Promise(() => {}), [
|
||||
{ family: "Hanken Grotesk", status: "loading" },
|
||||
{ family: "Inter", status: "loaded" },
|
||||
]);
|
||||
mountDeferredScripts();
|
||||
|
||||
await parseThenLoad();
|
||||
await vi.advanceTimersByTimeAsync(FONT_WAIT_TIMEOUT_MS - 1);
|
||||
expect(log()).toBe("");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(log()).toMatch(/^a .*b /);
|
||||
expect(posted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "diagnostic",
|
||||
code: "runtime_font_wait_timeout",
|
||||
details: { loadingFamilies: ["Hanken Grotesk"], timeoutMs: FONT_WAIT_TIMEOUT_MS },
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs deferred scripts once and in order through the page's fallback when the runtime predates the gate", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
claims(false);
|
||||
compilePage();
|
||||
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
expect(log()).toBe("a b ");
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("no web-font gate"));
|
||||
});
|
||||
|
||||
it("leaves the scripts to a runtime with the gate, which runs them once after fonts", async () => {
|
||||
let fontsLoaded = () => {};
|
||||
serveFonts(new Promise<void>((resolve) => (fontsLoaded = resolve)), []);
|
||||
claims(false);
|
||||
compilePage();
|
||||
|
||||
await parseThenLoad();
|
||||
await Promise.resolve();
|
||||
expect(log()).toBe("");
|
||||
fontsLoaded();
|
||||
await vi.waitFor(() => expect(window.__player).toBeDefined());
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
expect(log()).toBe("a b ");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { pretext } from "../text/pretext";
|
||||
import { assetUrl } from "./assetUrl";
|
||||
import { getVariables } from "./getVariables";
|
||||
import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./runtimeData";
|
||||
import { runScriptsAfterFonts } from "./afterFonts";
|
||||
import { AFTER_FONTS_SCRIPTS } from "../compiler/scriptRuns";
|
||||
|
||||
type HyperframeWindow = Window & {
|
||||
__hyperframeRuntimeBootstrapped?: boolean;
|
||||
@@ -56,8 +58,15 @@ function bootstrapHyperframeRuntime(): void {
|
||||
initSandboxRuntimeModular();
|
||||
}
|
||||
|
||||
// Compiled composition scripts wait for web fonts, so what they measure matches every run.
|
||||
function startAfterCompositionScripts(): void {
|
||||
const deferred = Array.from(document.querySelectorAll(AFTER_FONTS_SCRIPTS));
|
||||
if (deferred.length === 0) bootstrapHyperframeRuntime();
|
||||
else void runScriptsAfterFonts(deferred, bootstrapHyperframeRuntime);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", bootstrapHyperframeRuntime, { once: true });
|
||||
document.addEventListener("DOMContentLoaded", startAfterCompositionScripts, { once: true });
|
||||
} else {
|
||||
bootstrapHyperframeRuntime();
|
||||
startAfterCompositionScripts();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resetRuntimeDataForTests } from "./runtimeData";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope.js";
|
||||
import { wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||
import { AFTER_FONTS_SCRIPT_TYPE } from "../compiler/scriptRuns";
|
||||
|
||||
vi.mock("./mediaVolumeEnvelope.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./mediaVolumeEnvelope.js")>();
|
||||
@@ -105,7 +106,7 @@ function preview(scenes: Scene[], shared = "s1", sharedMarkup = "") {
|
||||
scenes
|
||||
.map(
|
||||
(s) =>
|
||||
`<script data-hf-scene="${s.id}">${sceneScript(s.id, s.label)}${s.script ?? ""}</script>`,
|
||||
`<script type="${AFTER_FONTS_SCRIPT_TYPE}" data-hf-scene="${s.id}">${sceneScript(s.id, s.label)}${s.script ?? ""}</script>`,
|
||||
)
|
||||
.join("");
|
||||
return {
|
||||
@@ -139,14 +140,15 @@ function mount(scenes: Scene[], root: Tl, editHead = (head: string) => head) {
|
||||
document.body.innerHTML = body;
|
||||
window.__timelines = { main: root };
|
||||
for (const s of scenes) window.__timelines[s.id] = made[s.label];
|
||||
// Run each scene script the swap appends when it is appended, as a browser would.
|
||||
const append = document.body.appendChild.bind(document.body);
|
||||
document.body.appendChild = <T extends Node>(node: T): T => {
|
||||
append(node);
|
||||
if (node instanceof HTMLScriptElement && node.hasAttribute("data-hf-scene"))
|
||||
new Function(node.textContent ?? "")();
|
||||
return node;
|
||||
};
|
||||
// Run each scene script the swap puts in place, as a browser runs a created script.
|
||||
const replaceWith = Element.prototype.replaceWith;
|
||||
vi.spyOn(Element.prototype, "replaceWith").mockImplementation(function (this: Element, ...nodes) {
|
||||
replaceWith.apply(this, nodes);
|
||||
for (const node of nodes) {
|
||||
if (node instanceof HTMLScriptElement && node.hasAttribute("data-hf-scene"))
|
||||
new Function(node.textContent ?? "")();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function boot(scenes: Scene[], root: Tl, editHead = (head: string) => head) {
|
||||
@@ -205,7 +207,6 @@ describe("__hfSwapScenes", () => {
|
||||
(window as unknown as { __made: typeof made }).__made = made;
|
||||
});
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(document.body, "appendChild");
|
||||
Reflect.deleteProperty(window, "gsap");
|
||||
window.__hfRuntimeTeardown?.();
|
||||
document.head.innerHTML = "";
|
||||
@@ -243,6 +244,26 @@ describe("__hfSwapScenes", () => {
|
||||
expect(JSON.parse(meta).scenes.a).toBe("ha2");
|
||||
});
|
||||
|
||||
it("runs the swapped scene's script only once web fonts are ready", async () => {
|
||||
const { root } = trackingRoot();
|
||||
boot([A1, B], root);
|
||||
await tick();
|
||||
let fontsLoaded = () => {};
|
||||
const ready = new Promise<void>((resolve) => (fontsLoaded = resolve));
|
||||
Object.defineProperty(document, "fonts", { configurable: true, value: { ready } });
|
||||
try {
|
||||
const swap = window.__hfSwapScenes!(preview([A2, B]).html);
|
||||
for (let i = 0; i < 5; i++) await tick();
|
||||
expect(window.__timelines.a).not.toBe(made.a2);
|
||||
|
||||
fontsLoaded();
|
||||
await swap;
|
||||
expect(window.__timelines.a).toBe(made.a2);
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, "fonts");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects without touching the film when anything outside the scenes changed", async () => {
|
||||
const { root } = trackingRoot();
|
||||
boot([A1, B], root);
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { createClipTree } from "./clipTree";
|
||||
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
|
||||
import { runScriptsAfterFonts } from "./afterFonts";
|
||||
import {
|
||||
applyCaptionOverrides,
|
||||
applyFetchedCaptionOverrides,
|
||||
@@ -3600,24 +3601,22 @@ export function initSandboxRuntimeModular(): void {
|
||||
.forEach((el, i) => el.replaceWith(document.importNode(newStyles[i]!, true)));
|
||||
for (const el of oldParts) if (el !== oldHost) el.remove();
|
||||
const host = document.importNode(newHost, true);
|
||||
// Text only: a live script that ran no longer carries the deferred type its new copy has.
|
||||
const scripts = (parts: Element[]) =>
|
||||
parts.flatMap((el) => (el.tagName === "SCRIPT" ? el.outerHTML : [])).join("");
|
||||
JSON.stringify(parts.flatMap((el) => (el.tagName === "SCRIPT" ? el.textContent : [])));
|
||||
keepUnchangedMedia(oldHost, host, scripts(oldParts) === scripts(newParts));
|
||||
oldHost.replaceWith(host);
|
||||
swappedHosts.push(host);
|
||||
if (host.querySelector(".caption-group")) captionHosts.push(host);
|
||||
}
|
||||
// Run once every host is replaced, so no new script binds to a scene still to be swapped.
|
||||
for (const { newParts } of swaps) {
|
||||
for (const el of newParts) {
|
||||
if (el.tagName !== "SCRIPT") continue;
|
||||
// An imported <script> never runs; a created one does.
|
||||
const script = document.createElement("script");
|
||||
for (const attr of Array.from(el.attributes)) script.setAttribute(attr.name, attr.value);
|
||||
script.textContent = el.textContent;
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
}
|
||||
const sceneScripts = swaps.flatMap(({ newParts }) =>
|
||||
newParts
|
||||
.filter((el) => el.tagName === "SCRIPT")
|
||||
.map((el) => document.body.appendChild(document.importNode(el, true))),
|
||||
);
|
||||
await runScriptsAfterFonts(sceneScripts);
|
||||
if (state.tornDown) throw new Error("the preview was torn down during the swap");
|
||||
document
|
||||
.querySelector(`meta[name="${SCENE_PARTS_META}"]`)
|
||||
?.setAttribute("content", JSON.stringify(nextParts));
|
||||
|
||||
@@ -1552,7 +1552,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _onIframeLoad() {
|
||||
// In video mode the iframe only ever loads about:blank; its load must not reset the video.
|
||||
if (!this._connected || this._videoSource) return;
|
||||
// The runtime posts its timeline at DOMContentLoaded, before `load`, and every
|
||||
// The runtime posts its timeline once its scripts ran, before or after `load`, and every
|
||||
// host-initiated navigation clears `_ready` first. So a ready player already holds this
|
||||
// document's handshake (an opaque origin reads as null); a paused runtime never posts it again.
|
||||
const doc = this._getSameOriginIframeDocument();
|
||||
@@ -1563,8 +1563,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
|
||||
this._ready = false;
|
||||
// The runtime installs its bridge at DOMContentLoaded, posts `ready`, and only then does the
|
||||
// iframe's load event fire. Do not erase that authoritative handshake here: doing so strands
|
||||
// The runtime installs its bridge once its scripts ran and posts `ready`, which can come before
|
||||
// the iframe's load event. Do not erase that authoritative handshake here: doing so strands
|
||||
// retained data set after load until a second `ready` that never comes. Source setters and
|
||||
// sandbox-policy reloads already clear bridge readiness before starting a navigation.
|
||||
this._invalidateAssetsWait();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AFTER_FONTS_SCRIPT_TYPE } from "@hyperframes/core/compiler";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -46,8 +47,13 @@ describe("compileForRender mounted module scripts", () => {
|
||||
});
|
||||
const { document } = parseHTML(html);
|
||||
const importMap = document.querySelector('script[type="importmap"]');
|
||||
const modules = [...document.querySelectorAll('script[type="module"]')];
|
||||
const classic = [...document.querySelectorAll("script:not([type])")].map((s) => s.textContent);
|
||||
// Deferred until fonts: the runtime runs each as the type its marker names.
|
||||
const modules = [
|
||||
...document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}+module"]`),
|
||||
];
|
||||
const classic = [...document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}"]`)].map(
|
||||
(s) => s.textContent,
|
||||
);
|
||||
|
||||
expect(JSON.parse(importMap?.textContent || "null")).toEqual({
|
||||
imports: { three: "./compositions/blk/assets/three.js" },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { AFTER_FONTS_SCRIPT_TYPE } from "@hyperframes/core/compiler";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -99,4 +101,43 @@ describe("compileForRender script order", () => {
|
||||
expect(lib).toBeGreaterThan(-1);
|
||||
expect(lib).toBeLessThan(before);
|
||||
});
|
||||
|
||||
it("defers every body script it emits until fonts, the position edit script and CDN libraries too", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("window.CDN_LIB = 1;"));
|
||||
const dir = project({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="180">
|
||||
<div id="block" data-composition-id="block" data-composition-src="compositions/block/block.html" data-width="320" data-height="180"></div>
|
||||
<div id="moved" data-hf-studio-path-offset="true"></div>
|
||||
</div>
|
||||
<script>window.ROOT_SCRIPT = 1;</script>
|
||||
<script src="assets/needs-before.js"></script>
|
||||
</body></html>`,
|
||||
"assets/needs-before.js": "void 0;",
|
||||
"compositions/block/block.html": `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="block" data-width="320" data-height="180"></div>
|
||||
<script src="https://cdn.example/lib.js"></script>
|
||||
<script>window.BLOCK_SCRIPT = 1;</script>
|
||||
</body></html>`,
|
||||
});
|
||||
const { html } = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
const { document } = parseHTML(html);
|
||||
const body = [...document.querySelectorAll("body script")];
|
||||
const deferred = [...document.querySelectorAll(`script[type="${AFTER_FONTS_SCRIPT_TYPE}"]`)];
|
||||
expect(deferred).toEqual(body);
|
||||
const text = deferred.map((el) => el.getAttribute("src") ?? el.textContent ?? "");
|
||||
for (const mark of [
|
||||
"ROOT_SCRIPT",
|
||||
"needs-before.js",
|
||||
"CDN_LIB",
|
||||
"BLOCK_SCRIPT",
|
||||
"data-hf-studio-path-offset",
|
||||
]) {
|
||||
expect(text.some((t) => t.includes(mark))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
inlineSubCompositions as inlineSubCompositionsShared,
|
||||
ensureExternalLinkTag,
|
||||
ensureExternalScriptTag,
|
||||
deferScriptsUntilFonts,
|
||||
emitMountedModuleScripts,
|
||||
prepareFlattenedInnerRoot,
|
||||
emitRootCompositionVariableStyles,
|
||||
@@ -127,6 +128,12 @@ function parseSubCompHtmlForValidity(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
function deferBodyScriptsUntilFonts(html: string): string {
|
||||
const { document } = parseHTML(html);
|
||||
deferScriptsUntilFonts(document as unknown as Document);
|
||||
return document.toString();
|
||||
}
|
||||
|
||||
export function injectSdkPositionEditsRenderScript(html: string): string {
|
||||
if (!html.includes("data-hf-edit-base-x") && !html.includes("data-hf-edit-base-y")) {
|
||||
return html;
|
||||
@@ -2036,7 +2043,9 @@ export async function compileForRender(
|
||||
`<script>${createStudioPositionSeekReapplyScript()}</script>`,
|
||||
) ?? assembledHtml)
|
||||
: assembledHtml;
|
||||
const htmlWithSdkPositionScript = injectSdkPositionEditsRenderScript(htmlWithPositionScript);
|
||||
const htmlWithDeferredScripts = deferBodyScriptsUntilFonts(
|
||||
injectSdkPositionEditsRenderScript(htmlWithPositionScript),
|
||||
);
|
||||
|
||||
// Download remote <video> and <audio> sources to compiledDir and rewrite the
|
||||
// src attributes so the renderer reads from localhost. Remote S3 URLs cause
|
||||
@@ -2044,7 +2053,7 @@ export async function compileForRender(
|
||||
// over the network; any that don't reach readyState >= 2 in time render as
|
||||
// blank black frames. Localising them eliminates the race.
|
||||
const { html: htmlWithLocalMedia, remoteMediaAssets } = await localizeRemoteMediaSources(
|
||||
htmlWithSdkPositionScript,
|
||||
htmlWithDeferredScripts,
|
||||
downloadDir,
|
||||
);
|
||||
|
||||
|
||||
@@ -451,8 +451,7 @@ if (typeof window !== "undefined") {
|
||||
// Expose a synchronous flush so headless renderers can drain the queue
|
||||
// instantly instead of waiting for rAF-based batch ticks. Also force-
|
||||
// publishes the "timelines built" signal immediately (normally deferred
|
||||
// via setTimeout(0)) — the caller guarantees page scripts have finished
|
||||
// loading, so no more operations can arrive after the flush.
|
||||
// via setTimeout(0)). Scripts that wait for fonts queue later; duration() drains them.
|
||||
(window as Record<string, unknown>).__hfFlushSync = () => {
|
||||
flushPendingOperations();
|
||||
if (pendingOperations.length === 0 && window.__hfTimelinesBuilding) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { AFTER_FONTS_SCRIPT_TYPE } from "@hyperframes/core/compiler";
|
||||
import { buildSubCompositionHtml, hasBaseElement } from "./subComposition";
|
||||
|
||||
function makeTempProject(files: Record<string, string>): string {
|
||||
@@ -121,6 +123,33 @@ describe("buildSubCompositionHtml", () => {
|
||||
expect(html!.slice(body)).not.toContain("<html");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a template", (body: string) => `<template>${body}</template>`],
|
||||
["a full document", (body: string) => `<!doctype html><html><body>${body}</body></html>`],
|
||||
["a fragment", (body: string) => body],
|
||||
])("defers the scripts of %s composition until fonts, but not the preview's own", (_, wrap) => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"compositions/card.html": wrap(
|
||||
`<div data-composition-id="card" data-width="400" data-height="300"><p>Hi</p>` +
|
||||
`<script>window.CARD = 1;</script><script type="module">window.CARD_MODULE = 1;</script></div>`,
|
||||
),
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(dir, "compositions/card.html", "/api/runtime.js")!;
|
||||
const types = [...parseHTML(html).document.querySelectorAll("script")].map((el) => [
|
||||
el.textContent?.trim() || el.getAttribute("src"),
|
||||
el.getAttribute("type"),
|
||||
]);
|
||||
expect(types).toEqual([
|
||||
["/api/runtime.js", null],
|
||||
[`${gsapCdnDist()}gsap.min.js`, null],
|
||||
["window.__timelines=window.__timelines||{};", null],
|
||||
["window.CARD = 1;", AFTER_FONTS_SCRIPT_TYPE],
|
||||
["window.CARD_MODULE = 1;", `${AFTER_FONTS_SCRIPT_TYPE}+module`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles raw fragment compositions (no template, no full document)", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "@hyperframes/core";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
||||
import { deferScriptsUntilFonts, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
||||
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
|
||||
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
|
||||
|
||||
@@ -310,6 +310,11 @@ export function buildSubCompositionHtml(
|
||||
// double-loaded AND the baked inline copy can fail to parse inline (the
|
||||
// "Unexpected token '<'" SyntaxError seen on comps with a baked runtime).
|
||||
rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
|
||||
const { document: scriptsDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${rewrittenContent}</body></html>`,
|
||||
);
|
||||
deferScriptsUntilFonts(scriptsDoc as unknown as Document);
|
||||
rewrittenContent = scriptsDoc.body.innerHTML;
|
||||
|
||||
// The comp's root carries data-composition-id but (unlike inlined sub-comps,
|
||||
// which inlineSubCompositions tags) no data-composition-file. Without it the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
closeSync,
|
||||
ftruncateSync,
|
||||
@@ -21,6 +22,7 @@ import { tmpdir } from "node:os";
|
||||
import { join, parse } from "node:path";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
|
||||
import { AFTER_FONTS_SCRIPT_TYPE } from "@hyperframes/core/compiler";
|
||||
import { PREVIEW_BUNDLE_OPTIONS, PREVIEW_CAPTURE_PARAM, registerPreviewRoutes } from "./preview";
|
||||
import { registerFileRoutes } from "./files";
|
||||
import { createPreviewDocumentStore } from "../helpers/previewDocumentStore";
|
||||
@@ -194,7 +196,11 @@ describe("registerPreviewRoutes", () => {
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain("__hfStudioMotionApply");
|
||||
const motionScript = [...parseHTML(html).document.querySelectorAll("script")].find((el) =>
|
||||
el.textContent?.includes("__hfStudioMotionApply"),
|
||||
);
|
||||
// Deferred with the composition scripts, so it still runs after them.
|
||||
expect(motionScript?.getAttribute("type")).toBe(AFTER_FONTS_SCRIPT_TYPE);
|
||||
expect(html).toContain("studio-motion");
|
||||
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
|
||||
});
|
||||
@@ -249,6 +255,41 @@ describe("registerPreviewRoutes", () => {
|
||||
expect(html).toContain("gsap@3/dist/MotionPathPlugin.min.js");
|
||||
// Plugin must load AFTER the core gsap script so it can register onto it.
|
||||
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("MotionPathPlugin.min.js"));
|
||||
const plugin = parseHTML(html).document.querySelector('script[src*="MotionPathPlugin"]');
|
||||
expect(plugin?.hasAttribute("type")).toBe(false);
|
||||
});
|
||||
|
||||
it("defers the MotionPathPlugin with a body gsap script, so it still runs right after gsap", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!doctype html><html><head></head><body><div id="card" class="clip"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#card", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] }, duration: 1 }, 0);
|
||||
window.__timelines = { index: tl };
|
||||
</script>
|
||||
</body></html>`,
|
||||
);
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(
|
||||
app,
|
||||
createAdapter(projectDir, {
|
||||
bundle: (dir, options) =>
|
||||
bundleToSingleHtml(dir, { ...PREVIEW_BUNDLE_OPTIONS, ...options }),
|
||||
}),
|
||||
);
|
||||
|
||||
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
|
||||
|
||||
const scripts = [...parseHTML(html).document.querySelectorAll("script[src]")];
|
||||
const gsapAt = scripts.findIndex((el) => el.getAttribute("src")?.endsWith("/gsap.min.js"));
|
||||
expect(scripts[gsapAt]?.getAttribute("type")).toBe(AFTER_FONTS_SCRIPT_TYPE);
|
||||
const plugin = scripts[gsapAt + 1];
|
||||
expect(plugin?.getAttribute("src")).toContain("gsap@3/dist/MotionPathPlugin.min.js");
|
||||
expect(plugin?.getAttribute("type")).toBe(AFTER_FONTS_SCRIPT_TYPE);
|
||||
});
|
||||
|
||||
it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Readable } from "node:stream";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
AFTER_FONTS_SCRIPT_TYPE,
|
||||
addScenePartsManifest,
|
||||
injectScriptsIntoHtml,
|
||||
insertBeforeCloseTag,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
type BundleOptions,
|
||||
@@ -158,7 +158,8 @@ function injectMotionPathPluginIfNeeded(html: string): string {
|
||||
const match = html.match(gsapScript);
|
||||
if (match) {
|
||||
const version = match[0].match(/gsap@([\d.]+)/)?.[1];
|
||||
const pluginTag = `<script src="${motionPathPluginUrl(version)}"></script>`;
|
||||
const gsapTagType = match[0].match(/\stype=("[^"]*"|'[^']*')/i)?.[0] ?? "";
|
||||
const pluginTag = `<script${gsapTagType} src="${motionPathPluginUrl(version)}"></script>`;
|
||||
const end = html.indexOf(match[0]) + match[0].length;
|
||||
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
|
||||
}
|
||||
@@ -186,12 +187,9 @@ function injectStudioMotionScript(
|
||||
activeCompositionPath,
|
||||
});
|
||||
if (!script) return html;
|
||||
return injectScriptsIntoHtml(
|
||||
injectStudioMotionDependencies(html, manifestContent),
|
||||
[],
|
||||
[script],
|
||||
false,
|
||||
);
|
||||
const withDependencies = injectStudioMotionDependencies(html, manifestContent);
|
||||
const tag = `<script type="${AFTER_FONTS_SCRIPT_TYPE}">${script.replace(/<\/script/gi, "<\\/script")}</script>`;
|
||||
return insertBeforeCloseTag(withDependencies, "body", tag) ?? `${withDependencies}${tag}`;
|
||||
}
|
||||
|
||||
const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
|
||||
|
||||
@@ -384,6 +384,56 @@ describe("ensureMotionPathPluginLoaded", () => {
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("when gsap is not there yet at load", () => {
|
||||
const post = (source: unknown, type = "ready") => {
|
||||
const event = new MessageEvent("message", { data: { source: "hf-preview", type } });
|
||||
Object.defineProperty(event, "source", { value: source });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
it("loads the plugin once that iframe's runtime is ready", () => {
|
||||
const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
contentWindow.gsap = { registerPlugin: vi.fn() };
|
||||
post({});
|
||||
post(contentWindow, "timeline");
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
post(contentWindow);
|
||||
post(contentWindow);
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
|
||||
});
|
||||
|
||||
it("stops listening at the first ready, even when it brought no gsap", () => {
|
||||
const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe({ gsap: undefined });
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
post(contentWindow);
|
||||
contentWindow.gsap = { registerPlugin: vi.fn() };
|
||||
post(contentWindow);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops a preview that never became ready once a newer one loads", () => {
|
||||
const stale = buildBootstrapIframe({ gsap: undefined });
|
||||
ensureMotionPathPluginLoaded(stale.iframe);
|
||||
ensureMotionPathPluginLoaded(buildBootstrapIframe({ gsap: undefined }).iframe);
|
||||
stale.contentWindow.gsap = { registerPlugin: vi.fn() };
|
||||
post(stale.contentWindow);
|
||||
expect(stale.appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not wait when the runtime already booted, since its ready has gone out", () => {
|
||||
const { iframe, contentWindow, appendedScripts } = buildBootstrapIframe({
|
||||
gsap: undefined,
|
||||
__playerReady: true,
|
||||
});
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
contentWindow.gsap = { registerPlugin: vi.fn() };
|
||||
post(contentWindow);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the plugin at the composition's own gsap version", () => {
|
||||
const { iframe, appendedScripts } = buildBootstrapIframe({
|
||||
gsap: { version: "3.14.2", registerPlugin: vi.fn() },
|
||||
|
||||
@@ -14,6 +14,7 @@ type IframeWindow = Window & {
|
||||
// reloads (each needing the plugin) don't queue duplicate plugin scripts that
|
||||
// re-flash the iframe. Cleared once the plugin loads or errors.
|
||||
__hfMotionPathPluginLoading?: boolean;
|
||||
__playerReady?: boolean;
|
||||
gsap?: {
|
||||
version?: string;
|
||||
timeline?: (...args: unknown[]) => unknown;
|
||||
@@ -37,10 +38,15 @@ type IframeWindow = Window & {
|
||||
* studio edits.
|
||||
*
|
||||
* Idempotent (no-ops once the plugin is present or already loading) and
|
||||
* defensive: no-ops without gsap/registerPlugin and tolerates a CDN failure
|
||||
* defensive: without gsap it waits for the runtime's ready, and it tolerates a CDN failure
|
||||
* (the soft-reload async fallback in applySoftReload still covers that case).
|
||||
*/
|
||||
// At most one preview waits for its runtime; a newer load replaces it, so a frame that never boots is not kept alive.
|
||||
let pendingReadyRetry: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
|
||||
if (pendingReadyRetry) window.removeEventListener("message", pendingReadyRetry);
|
||||
pendingReadyRetry = null;
|
||||
if (!iframe?.contentWindow || !iframe.contentDocument) return;
|
||||
const win = iframe.contentWindow as IframeWindow;
|
||||
const doc = iframe.contentDocument;
|
||||
@@ -53,7 +59,21 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (!win.gsap?.registerPlugin) return;
|
||||
// A body gsap runs after web fonts, which can be after the iframe's load: retry once the runtime is ready.
|
||||
// A booted runtime (`__playerReady`, set in the same task it posts ready) has nothing left to wait for.
|
||||
if (!win.gsap?.registerPlugin) {
|
||||
if (win.__playerReady) return;
|
||||
const retry = (event: MessageEvent) => {
|
||||
const data = event.data as { source?: unknown; type?: unknown } | null;
|
||||
if (event.source !== win || data?.source !== "hf-preview" || data.type !== "ready") return;
|
||||
window.removeEventListener("message", retry);
|
||||
pendingReadyRetry = null;
|
||||
if (win.gsap?.registerPlugin) ensureMotionPathPluginLoaded(iframe);
|
||||
};
|
||||
pendingReadyRetry = retry;
|
||||
window.addEventListener("message", retry);
|
||||
return;
|
||||
}
|
||||
// A load is already in flight for this iframe — don't queue a second script.
|
||||
if (win.__hfMotionPathPluginLoading) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user