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:
Miguel Ángel
2026-07-14 21:55:51 -04:00
committed by GitHub
parent 5d3a7404fa
commit 7382fabab9
12 changed files with 364 additions and 13 deletions
@@ -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>
+19
View File
@@ -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),
};
+46 -11
View File
@@ -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,
+37
View File
@@ -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");
+11 -1
View File
@@ -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 = [];
+1
View File
@@ -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([]);
});
+2
View File
@@ -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",