mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): consolidate external asset and dependency preservation (#2410)
## Summary - preserve external SVG fragment references during bundling - preserve external module scripts and serve `.mjs` with a JavaScript MIME type - retain template-head stylesheets when mounting sub-compositions - add regression coverage across compiler runtime and file-server paths Consolidates and replaces #2390, #2297, and #2375. ## Verification - core compiler/runtime tests: 89 passed - producer file-server tests: 48 passed - core, producer, engine, and CLI typechecks passed - `git diff --check`
This commit is contained in:
@@ -62,6 +62,7 @@ export function injectRuntime(html: string): string {
|
||||
|
||||
const ASSET_CONTENT_TYPES: Record<string, string> = {
|
||||
js: "application/javascript",
|
||||
mjs: "application/javascript",
|
||||
css: "text/css",
|
||||
json: "application/json",
|
||||
png: "image/png",
|
||||
|
||||
@@ -95,6 +95,34 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).not.toContain("./bg.svg");
|
||||
});
|
||||
|
||||
it("preserves external SVG fragment references used by <use>", async () => {
|
||||
const spriteSvg = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="patch-head" viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" /></symbol>
|
||||
</svg>`;
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180" data-start="0" data-duration="1">
|
||||
<svg>
|
||||
<use id="href-use" href="assets/patch.svg#patch-head"></use>
|
||||
<use id="xlink-use" xlink:href="assets/patch.svg#patch-head"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines.main = {}</script>
|
||||
</body></html>`,
|
||||
"assets/patch.svg": spriteSvg,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
const { document } = parseHTML(bundled);
|
||||
expect(document.getElementById("href-use")?.getAttribute("href")).toBe(
|
||||
"assets/patch.svg#patch-head",
|
||||
);
|
||||
expect(document.getElementById("xlink-use")?.getAttribute("xlink:href")).toBe(
|
||||
"assets/patch.svg#patch-head",
|
||||
);
|
||||
expect(bundled).not.toContain("data:image/svg+xml;base64");
|
||||
});
|
||||
|
||||
it("does not merge author scripts into the runtime bootstrap placeholder", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
@@ -405,6 +433,22 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).not.toContain('src="vendor/effect-plugin.js"');
|
||||
});
|
||||
|
||||
it("preserves local module scripts and their import base URL", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><body>
|
||||
<div data-composition-id="main" data-start="0" data-duration="1"></div>
|
||||
<script type="module" src="./module.js"></script>
|
||||
</body></html>`,
|
||||
"module.js": `import { value } from "./value.js"; window.result = value;`,
|
||||
"value.js": `export const value = "loaded";`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
expect(bundled).toMatch(/<script\b[^>]*\btype="module"[^>]*\bsrc="\.\/module\.js"/);
|
||||
expect(bundled).not.toContain('import { value } from "./value.js"');
|
||||
});
|
||||
|
||||
it("preserves local sub-composition script order before inline scene scripts", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -310,6 +310,16 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
|
||||
return appendSuffixToUrl(dataUrl, suffix);
|
||||
}
|
||||
|
||||
function isExternalSvgFragmentUse(el: Element, attr: string, urlValue: string): boolean {
|
||||
if (el.tagName.toLowerCase() !== "use") return false;
|
||||
if (attr !== "href" && attr !== "xlink:href") return false;
|
||||
if (!isRelativeUrl(urlValue)) return false;
|
||||
const hashIdx = urlValue.indexOf("#");
|
||||
if (hashIdx <= 0) return false;
|
||||
const pathBeforeFragment = urlValue.slice(0, hashIdx).split("?", 1)[0] ?? "";
|
||||
return pathBeforeFragment.toLowerCase().endsWith(".svg");
|
||||
}
|
||||
|
||||
function warnColorGradingLutNotInlined(lutSrc: string): void {
|
||||
const trimmed = lutSrc.trim();
|
||||
if (!isRelativeUrl(trimmed)) return;
|
||||
@@ -843,6 +853,10 @@ export async function bundleToSingleHtml(
|
||||
for (const el of [...document.querySelectorAll("script[src]")]) {
|
||||
const src = el.getAttribute("src");
|
||||
if (!src || !isRelativeUrl(src)) continue;
|
||||
// Module scripts can contain static imports whose resolution is relative
|
||||
// to the script URL. Folding their source into a classic inline script
|
||||
// both drops module semantics and changes the import base URL.
|
||||
if ((el.getAttribute("type") || "").trim().toLowerCase() === "module") continue;
|
||||
const jsPath = resolveEntryPath(src);
|
||||
const js = jsPath ? safeReadFile(jsPath) : null;
|
||||
if (js == null) continue;
|
||||
@@ -1070,6 +1084,11 @@ export async function bundleToSingleHtml(
|
||||
for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (!value) continue;
|
||||
// Chromium requires external SVG <use> fragments to be same-origin with
|
||||
// the document. Converting the sprite to a data: URL makes it an opaque
|
||||
// origin and triggers "Unsafe attempt to load URL ... from frame".
|
||||
// Keep the project-relative URL; render/check servers already expose it.
|
||||
if (isExternalSvgFragmentUse(el, attr, value)) continue;
|
||||
const inlined = maybeInlineRelativeAssetUrl(value, projectDir);
|
||||
if (inlined) el.setAttribute(attr, inlined);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ beforeAll(() => {
|
||||
describe("loadExternalCompositions", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
document.head.querySelectorAll("style").forEach((s) => s.remove());
|
||||
document.head.querySelectorAll("style, link").forEach((node) => node.remove());
|
||||
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).gsap;
|
||||
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).__selectedTitle;
|
||||
delete (window as Window & { __hyperframes?: unknown }).__hyperframes;
|
||||
@@ -26,6 +26,7 @@ describe("loadExternalCompositions", () => {
|
||||
const defaultParams = {
|
||||
injectedStyles: [] as HTMLStyleElement[],
|
||||
injectedScripts: [] as HTMLScriptElement[],
|
||||
injectedLinks: [] as HTMLLinkElement[],
|
||||
parseDimensionPx: (v: string | null) => (v ? `${v}px` : null),
|
||||
};
|
||||
|
||||
@@ -89,6 +90,186 @@ describe("loadExternalCompositions", () => {
|
||||
expect(injectedStyles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves head stylesheets when an external composition uses a template", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html>
|
||||
<head><link rel="stylesheet" href="./scene.css"></head>
|
||||
<body>
|
||||
<template id="scene-template">
|
||||
<div data-composition-id="scene"><p>Styled scene</p></div>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
expect(
|
||||
document.head.querySelector(
|
||||
'link[rel="stylesheet"][href="https://example.com/compositions/scene.css"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not resolve an empty stylesheet href to the composition HTML", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html>
|
||||
<head><link rel="stylesheet" href=""></head>
|
||||
<body>
|
||||
<template id="scene-template">
|
||||
<div data-composition-id="scene"><p>Unstyled scene</p></div>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
expect(
|
||||
document.head.querySelector(
|
||||
'link[rel="stylesheet"][href="https://example.com/compositions/scene.html"]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not inject stylesheet href variants that resolve to the composition document", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href=" ">
|
||||
<link rel="stylesheet" href="#theme">
|
||||
<link rel="stylesheet" href="?v=1">
|
||||
<link rel="stylesheet" href="./scene.html?v=2#theme">
|
||||
<link rel="stylesheet" href="./scene.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<template id="scene-template">
|
||||
<div data-composition-id="scene"><p>Styled scene</p></div>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const injectedHrefs = Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
||||
(link) => (link as HTMLLinkElement).href,
|
||||
);
|
||||
expect(injectedHrefs).toEqual(["https://example.com/compositions/scene.css?v=1"]);
|
||||
});
|
||||
|
||||
it("does not execute head script src variants that resolve to the composition document", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<script src="#theme"></script>
|
||||
<script src="?v=1"></script>
|
||||
<script src="./scene.html?v=2#theme"></script>
|
||||
</head>
|
||||
<body><div data-composition-id="scene"><p>Scene</p></div></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||
vi.spyOn(document.body, "appendChild").mockImplementation((node: Node) => {
|
||||
const appended = originalAppendChild(node);
|
||||
if (node instanceof HTMLScriptElement && node.src) {
|
||||
queueMicrotask(() => node.dispatchEvent(new Event("load")));
|
||||
}
|
||||
return appended;
|
||||
});
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
await loadExternalCompositions({ ...defaultParams, injectedScripts });
|
||||
|
||||
expect(injectedScripts).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not execute content script src variants that resolve to the composition document", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html>
|
||||
<body>
|
||||
<div data-composition-id="scene">
|
||||
<p>Scene</p>
|
||||
<script src="#theme"></script>
|
||||
<script src="?v=1"></script>
|
||||
<script src="./scene.html?v=2#theme"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
await loadExternalCompositions({ ...defaultParams, injectedScripts });
|
||||
|
||||
expect(injectedScripts).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not fail composition mounting when a head script src is malformed", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html>
|
||||
<head><script src="http://[invalid"></script></head>
|
||||
<body><div data-composition-id="scene"><p>Scene</p></div></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||
vi.spyOn(document.body, "appendChild").mockImplementation((node: Node) => {
|
||||
const appended = originalAppendChild(node);
|
||||
if (node instanceof HTMLScriptElement && node.src) {
|
||||
queueMicrotask(() => node.dispatchEvent(new Event("load")));
|
||||
}
|
||||
return appended;
|
||||
});
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
const onDiagnostic = vi.fn();
|
||||
await loadExternalCompositions({ ...defaultParams, injectedScripts, onDiagnostic });
|
||||
|
||||
expect(injectedScripts).toHaveLength(1);
|
||||
expect(onDiagnostic).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onDiagnostic when fetch fails", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/broken.html");
|
||||
@@ -1021,6 +1202,7 @@ describe("loadInlineTemplateCompositions", () => {
|
||||
const defaultParams = {
|
||||
injectedStyles: [] as HTMLStyleElement[],
|
||||
injectedScripts: [] as HTMLScriptElement[],
|
||||
injectedLinks: [] as HTMLLinkElement[],
|
||||
parseDimensionPx: (v: string | null) => (v ? `${v}px` : null),
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type LoadExternalCompositionsParams = {
|
||||
injectedStyles: HTMLStyleElement[];
|
||||
injectedScripts: HTMLScriptElement[];
|
||||
injectedLinks: HTMLLinkElement[];
|
||||
parseDimensionPx: (value: string | null) => string | null;
|
||||
onDiagnostic?: (payload: {
|
||||
code: string;
|
||||
@@ -193,7 +194,11 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
|
||||
const trimmedSrc = scriptSrc.trim();
|
||||
if (!trimmedSrc) return scriptSrc;
|
||||
try {
|
||||
if (BARE_RELATIVE_PATH_RE.test(trimmedSrc)) {
|
||||
if (
|
||||
BARE_RELATIVE_PATH_RE.test(trimmedSrc) &&
|
||||
!trimmedSrc.startsWith("#") &&
|
||||
!trimmedSrc.startsWith("?")
|
||||
) {
|
||||
// Composition payloads may use root-relative semantics without a leading slash.
|
||||
return new URL(trimmedSrc, document.baseURI).toString();
|
||||
}
|
||||
@@ -206,6 +211,22 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
|
||||
}
|
||||
}
|
||||
|
||||
function isSameDocumentUrl(candidate: string | URL, compositionUrl: URL): boolean {
|
||||
try {
|
||||
const candidateDocumentUrl = new URL(candidate);
|
||||
const compositionDocumentUrl = new URL(compositionUrl);
|
||||
candidateDocumentUrl.search = "";
|
||||
candidateDocumentUrl.hash = "";
|
||||
compositionDocumentUrl.search = "";
|
||||
compositionDocumentUrl.hash = "";
|
||||
return candidateDocumentUrl.href === compositionDocumentUrl.href;
|
||||
} catch {
|
||||
// Invalid authored URLs are not self-references. Preserve the existing
|
||||
// browser-load path so its failure remains isolated to the script itself.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type HostCompositionIdentity = {
|
||||
authoredCompositionId: string | null;
|
||||
runtimeCompositionId: string | null;
|
||||
@@ -345,6 +366,7 @@ async function mountCompositionContent(params: {
|
||||
compositionUrl: URL | null;
|
||||
injectedStyles: HTMLStyleElement[];
|
||||
injectedScripts: HTMLScriptElement[];
|
||||
injectedLinks: HTMLLinkElement[];
|
||||
parseDimensionPx: (value: string | null) => string | null;
|
||||
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
|
||||
headStyles?: HTMLStyleElement[];
|
||||
@@ -390,10 +412,15 @@ async function mountCompositionContent(params: {
|
||||
|
||||
if (params.headLinks) {
|
||||
for (const link of params.headLinks) {
|
||||
const href = link.getAttribute("href") || "";
|
||||
if (!href) continue;
|
||||
const rawHref = (link.getAttribute("href") || "").trim();
|
||||
if (!rawHref) continue;
|
||||
const href = params.compositionUrl ? new URL(rawHref, params.compositionUrl).href : rawHref;
|
||||
if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue;
|
||||
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
|
||||
document.head.appendChild(link.cloneNode(true));
|
||||
const clonedLink = link.cloneNode(true) as HTMLLinkElement;
|
||||
clonedLink.href = href;
|
||||
document.head.appendChild(clonedLink);
|
||||
params.injectedLinks.push(clonedLink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +460,9 @@ async function mountCompositionContent(params: {
|
||||
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
||||
if (scriptSrc) {
|
||||
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
||||
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
|
||||
continue;
|
||||
}
|
||||
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
|
||||
} else {
|
||||
const scriptText = script.textContent?.trim() ?? "";
|
||||
@@ -455,6 +485,10 @@ async function mountCompositionContent(params: {
|
||||
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
||||
if (scriptSrc) {
|
||||
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
||||
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
|
||||
script.parentNode?.removeChild(script);
|
||||
continue;
|
||||
}
|
||||
scriptPayloads.push({
|
||||
kind: "external",
|
||||
src: resolvedSrc,
|
||||
@@ -586,6 +620,7 @@ export async function loadInlineTemplateCompositions(
|
||||
compositionUrl: null,
|
||||
injectedStyles: params.injectedStyles,
|
||||
injectedScripts: params.injectedScripts,
|
||||
injectedLinks: params.injectedLinks,
|
||||
parseDimensionPx: params.parseDimensionPx,
|
||||
onDiagnostic: params.onDiagnostic,
|
||||
});
|
||||
@@ -636,6 +671,7 @@ export async function loadExternalCompositions(
|
||||
compositionUrl,
|
||||
injectedStyles: params.injectedStyles,
|
||||
injectedScripts: params.injectedScripts,
|
||||
injectedLinks: params.injectedLinks,
|
||||
parseDimensionPx: params.parseDimensionPx,
|
||||
onDiagnostic: params.onDiagnostic,
|
||||
});
|
||||
@@ -677,13 +713,11 @@ export async function loadExternalCompositions(
|
||||
const headScripts = !template
|
||||
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
|
||||
: undefined;
|
||||
const headLinks = !template
|
||||
? Array.from(
|
||||
doc.head.querySelectorAll<HTMLLinkElement>(
|
||||
'link[rel="stylesheet"], link[rel="preconnect"]',
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const headLinks = Array.from(
|
||||
doc.head.querySelectorAll<HTMLLinkElement>(
|
||||
'link[rel="stylesheet"], link[rel="preconnect"]',
|
||||
),
|
||||
);
|
||||
await mountCompositionContent({
|
||||
host,
|
||||
authoredCompositionId,
|
||||
@@ -695,6 +729,7 @@ export async function loadExternalCompositions(
|
||||
compositionUrl,
|
||||
injectedStyles: params.injectedStyles,
|
||||
injectedScripts: params.injectedScripts,
|
||||
injectedLinks: params.injectedLinks,
|
||||
parseDimensionPx: params.parseDimensionPx,
|
||||
headStyles,
|
||||
headScripts,
|
||||
|
||||
@@ -367,6 +367,43 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(child.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("removes external composition head links during runtime teardown", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const child = document.createElement("div");
|
||||
child.setAttribute("data-composition-id", "sub");
|
||||
child.setAttribute("data-composition-src", "https://example.com/compositions/sub.html");
|
||||
child.setAttribute("data-start", "0");
|
||||
child.setAttribute("data-duration", "3");
|
||||
root.appendChild(child);
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
`<html><head><link rel="stylesheet" href="./sub.css"></head><body><template id="sub-template"><div data-composition-id="sub">Sub</div></template></body></html>`,
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
window.__timelines = { main: createMockTimeline(3), sub: createMockTimeline(3) };
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
const injectedLink = document.head.querySelector<HTMLLinkElement>(
|
||||
'link[href="https://example.com/compositions/sub.css"]',
|
||||
);
|
||||
expect(injectedLink).not.toBeNull();
|
||||
|
||||
window.__hfRuntimeTeardown?.();
|
||||
|
||||
expect(injectedLink?.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps compiled external composition hosts visible through their authored duration", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -2042,6 +2042,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const compositionLoaderParams = {
|
||||
injectedStyles: state.injectedCompStyles,
|
||||
injectedScripts: state.injectedCompScripts,
|
||||
injectedLinks: state.injectedCompLinks,
|
||||
parseDimensionPx,
|
||||
onDiagnostic: ({
|
||||
code,
|
||||
@@ -3047,12 +3048,21 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
}
|
||||
state.injectedCompStyles = [];
|
||||
for (const linkEl of state.injectedCompLinks) {
|
||||
try {
|
||||
linkEl.remove();
|
||||
} catch (err) {
|
||||
// ignore cleanup failures
|
||||
swallow("runtime.init.site15", err);
|
||||
}
|
||||
}
|
||||
state.injectedCompLinks = [];
|
||||
for (const scriptEl of state.injectedCompScripts) {
|
||||
try {
|
||||
scriptEl.remove();
|
||||
} catch (err) {
|
||||
// ignore cleanup failures
|
||||
swallow("runtime.init.site15", err);
|
||||
swallow("runtime.init.site16", err);
|
||||
}
|
||||
}
|
||||
state.injectedCompScripts = [];
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("createRuntimeState", () => {
|
||||
expect(state.cachedVideoClips).toEqual([]);
|
||||
expect(state.injectedCompStyles).toEqual([]);
|
||||
expect(state.injectedCompScripts).toEqual([]);
|
||||
expect(state.injectedCompLinks).toEqual([]);
|
||||
expect(state.deterministicAdapters).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export type RuntimeState = {
|
||||
beforeUnloadHandler: (() => void) | null;
|
||||
injectedCompStyles: HTMLStyleElement[];
|
||||
injectedCompScripts: HTMLScriptElement[];
|
||||
injectedCompLinks: HTMLLinkElement[];
|
||||
cachedTimedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
cachedMediaClips: RuntimeMediaClip[];
|
||||
cachedVideoClips: RuntimeMediaClip[];
|
||||
@@ -116,6 +117,7 @@ export function createRuntimeState(): RuntimeState {
|
||||
beforeUnloadHandler: null,
|
||||
injectedCompStyles: [],
|
||||
injectedCompScripts: [],
|
||||
injectedCompLinks: [],
|
||||
cachedTimedMediaEls: [],
|
||||
cachedMediaClips: [],
|
||||
cachedVideoClips: [],
|
||||
|
||||
@@ -16,6 +16,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
|
||||
@@ -315,6 +315,24 @@ describe("parseRangeHeader", () => {
|
||||
});
|
||||
|
||||
describe("createFileServer", () => {
|
||||
it("serves ES modules with a JavaScript MIME type", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-mjs-"));
|
||||
try {
|
||||
writeEmptyIndex(projectDir);
|
||||
writeFileSync(join(projectDir, "scene.mjs"), "export const scene = true;");
|
||||
const server = await createFileServer({ projectDir, preHeadScripts: [], headScripts: [] });
|
||||
try {
|
||||
const response = await fetch(`${server.url}/scene.mjs`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/javascript; charset=utf-8");
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function expectInjectedRenderFps(
|
||||
fps: Parameters<typeof createFileServer>[0]["fps"],
|
||||
expected: {
|
||||
|
||||
@@ -79,6 +79,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".cube": "text/plain; charset=utf-8",
|
||||
".png": "image/png",
|
||||
|
||||
Reference in New Issue
Block a user