fix(parsers): render bare fragment sub-compositions (#4335)

This commit is contained in:
Miguel Ángel
2026-09-23 12:49:04 -04:00
committed by GitHub
parent 30f548f62b
commit 0618fc0dc3
16 changed files with 207 additions and 24 deletions
@@ -29,11 +29,13 @@ const STYLE_SELECTOR = "style";
const SCRIPT_SELECTOR = "script";
/**
* `<head>` links both paths hoist into the host document. Stylesheets carry
* Links both paths hoist into the host document. Stylesheets carry
* webfonts a composition's CSS depends on; preconnects are their latency hint.
*/
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';
export const EXTRACTED_COMPOSITION_ASSET_SELECTOR = `${STYLE_SELECTOR}, ${SCRIPT_SELECTOR}, ${HOISTED_LINK_SELECTOR}`;
/**
* The compiler's nesting cap, enforced against the ancestry chain rather than a
* counter so a wide tree is not penalised for a deep sibling.
@@ -131,7 +133,7 @@ export interface CompositionAssemblyPlan<TElement extends AssemblyAttributed> {
*/
scriptSources: TElement[];
/** `<head>` links to hoist into the host document. */
/** Head and content links to hoist into the host document. */
linkSources: TElement[];
/**
@@ -189,7 +191,10 @@ export function planCompositionAssembly<TElement extends AssemblyAttributed>(
...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
],
linkSources: toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
linkSources: [
...toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
...toArray(contentNode.querySelectorAll(HOISTED_LINK_SELECTOR)),
],
variableDefaultCarriers: [documentElement, innerRoot].filter(
(carrier): carrier is TElement => carrier != null,
),
@@ -68,6 +68,26 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
expect(result.scripts).toHaveLength(0);
});
it("hoists stylesheet and preconnect links from a bare fragment", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => `<link rel="stylesheet" href="theme.css">
<link rel="preconnect" href="https://fonts.example" crossorigin>
<link rel="icon" href="favicon.ico">
<div data-composition-id="intro"><link rel="stylesheet" href="nested.css"><link rel="icon" href="keep.ico"><div class="title">Hello</div></div>`,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toEqual([
{ href: "theme.css", rel: "stylesheet", crossorigin: undefined },
{ href: "https://fonts.example", rel: "preconnect", crossorigin: "" },
{ href: "nested.css", rel: "stylesheet", crossorigin: undefined },
]);
expect(host.querySelectorAll('link[rel="stylesheet"]')).toHaveLength(0);
expect(host.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
});
it("passes the failure reason through to onMissingComposition", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
@@ -22,8 +22,15 @@ import {
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
} from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
import { enumerateNestedCompositionHosts, planCompositionAssembly } from "./compositionAssembly";
import {
checkSubCompositionUsability,
resolveSubCompositionContent,
} from "@hyperframes/parsers/sub-composition-validity";
import {
enumerateNestedCompositionHosts,
planCompositionAssembly,
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
} from "./compositionAssembly";
// ---------------------------------------------------------------------------
// Public interface
@@ -154,7 +161,7 @@ function defaultBuildScopeSelector(compId: string): string {
* Inline sub-compositions into a document. For each host element in `hosts`:
*
* 1. Resolve the sub-composition HTML via `options.resolveHtml`
* 2. Parse it, find `<template>` or `<body>` content
* 2. Parse it, resolve template, body, or bare-fragment content
* 3. Find the inner `[data-composition-id]` root
* 4. Extract `<style>` elements, scope CSS, collect them
* 5. Extract `<script>` elements, wrap inline scripts, collect them
@@ -234,9 +241,7 @@ export function inlineSubCompositions(
runtimeCompId = compId || "";
}
// Find content: prefer <template>, fall back to <body>
const contentRoot = compDoc.querySelector("template");
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
const { contentHtml, hasTemplate } = resolveSubCompositionContent(compHtml, compDoc);
if (!contentHtml.trim()) {
onMissingComposition?.(src);
continue;
@@ -255,7 +260,7 @@ export function inlineSubCompositions(
contentNode: contentDoc,
head: compDoc.head,
documentElement: compDoc.documentElement,
hasTemplate: Boolean(contentRoot),
hasTemplate,
compositionId: compId,
});
const innerRoot = plan.innerRoot;
@@ -308,7 +313,7 @@ export function inlineSubCompositions(
// <link> hoisting is unconditional. A templated sub-composition's webfont
// link is as load-bearing as a non-templated one's, and the mount path has
// always hoisted both; gating this on `!contentRoot` dropped a templated
// always hoisted both; gating this on `!hasTemplate` dropped a templated
// composition's font from the render while preview kept it.
for (const link of plan.linkSources) {
const href = resolveSubAssetPath(link.getAttribute("href"));
@@ -408,7 +413,8 @@ export function inlineSubCompositions(
// Inject content into the host element
if (innerRoot) {
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
for (const child of [...innerRoot.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)])
child.remove();
if (flattenInnerRoot) {
const prepared = flattenInnerRoot(innerRoot);
if (!compId && scopeCompId) {
@@ -432,7 +438,8 @@ export function inlineSubCompositions(
}
}
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
for (const child of [...contentDoc.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)])
child.remove();
// linkedom fragment parsing: when content is `<div data-composition-id="X">...</div>`,
// the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML
// to preserve the composition wrapper.
@@ -127,6 +127,28 @@ describe("loadExternalCompositions", () => {
expect(host.querySelector("p")?.textContent).toBe("Scene");
});
it.each(["scene", "different-host-id"])(
"hoists fragment stylesheets once for %s",
async (hostId) => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
host.setAttribute("data-composition-id", hostId);
document.body.appendChild(host);
const html = `<div data-composition-id="scene">
<link rel="stylesheet" href="scene.css"><link rel="icon" href="icon.svg"><p>Styled scene</p></div>`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(html, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
expect(
document.head.querySelectorAll('link[href="https://example.com/compositions/scene.css"]'),
).toHaveLength(1);
expect(host.querySelectorAll('link[rel="stylesheet"]')).toHaveLength(0);
expect(host.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
expect(host.querySelector("p")?.textContent).toBe("Styled scene");
},
);
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");
@@ -1,4 +1,7 @@
import { planCompositionAssembly } from "../compiler/compositionAssembly";
import {
planCompositionAssembly,
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
} from "../compiler/compositionAssembly";
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
import { markFlattenedInnerRoot } from "./flattenedRoot";
import {
@@ -193,7 +196,7 @@ function resetCompositionHost(host: Element) {
* inline-template path, and mutating it would leave a remount with no styles.
*/
function stripExtractedCompositionAssets(node: ParentNode): void {
for (const el of Array.from(node.querySelectorAll("style, script"))) {
for (const el of Array.from(node.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR))) {
el.remove();
}
}
@@ -533,6 +536,7 @@ async function mountCompositionContent(params: {
params.host.appendChild(mountedContent);
} else {
params.host.innerHTML = params.fallbackBodyInnerHtml;
stripExtractedCompositionAssets(params.host);
}
// Stash the per-instance variables BEFORE running scripts. The scoped
@@ -31,6 +31,24 @@ describe("checkSubCompositionUsability", () => {
expect(checkSubCompositionUsability(VALID_HTML_NO_TEMPLATE, parse)).toEqual({ ok: true });
});
it.each([
'<div data-composition-id="intro">Hello</div>',
'<style>.title { color: red }</style><div data-composition-id="intro">Hello</div><script>window.sceneLoaded = true;</script>',
])("accepts a bare fragment: %s", (html) => {
expect(checkSubCompositionUsability(html, parse)).toEqual({ ok: true });
});
it("keeps an empty template authoritative over body content", () => {
const html =
'<html><body><template></template><div data-composition-id="intro">Hello</div></body></html>';
expect(checkSubCompositionUsability(html, parse).reason).toBe("no-content");
});
it("does not use head content when the body is empty", () => {
const html = '<html><head><meta data-composition-id="intro"></head><body></body></html>';
expect(checkSubCompositionUsability(html, parse).reason).toBe("no-content");
});
it("rejects an empty string without ever calling parseHtml (avoids the linkedom null-deref crash)", () => {
let parseCalled = false;
const spyParse = (html: string): ParsableDocumentLike => {
+17 -8
View File
@@ -57,13 +57,26 @@ export interface ParsableDocumentLike {
querySelector(selector: string): { innerHTML?: string | null } | null;
}
/** Resolve authored content without invoking linkedom's synthetic body getter. */
export function resolveSubCompositionContent(html: string, document: ParsableDocumentLike) {
const template = document.querySelector("template");
if (template) return { contentHtml: template.innerHTML ?? "", hasTemplate: true };
const body = document.querySelector("body");
if (body) return { contentHtml: body.innerHTML ?? "", hasTemplate: false };
// Browsers create a body for bare fragments; linkedom keeps their top-level nodes.
const contentHtml = document.querySelector("html") ? "" : html;
return { contentHtml, hasTemplate: false };
}
/**
* Check whether `html` (the raw file contents resolved for a
* `data-composition-src` reference) is non-empty and parses to a document
* that actually contains renderable content.
*
* Mirrors the content-detection steps in `inlineSubCompositions` exactly
* (resolve → parse → find `<template>` or `<body>` content → parse that →
* (resolve → parse → resolve template, body, or fragment content → parse that →
* confirm a `[data-composition-id]` root exists in it), so a file that
* passes this check is guaranteed to produce non-empty output from the
* inliner, and a file that fails it is guaranteed to hit one of the
@@ -97,10 +110,7 @@ export function checkSubCompositionUsability(
};
}
// Find content: prefer <template>, fall back to <body> — same precedence
// inlineSubCompositions uses when extracting the sub-composition's markup.
const contentRoot = compDoc.querySelector("template");
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
const { contentHtml } = resolveSubCompositionContent(html, compDoc);
if (!contentHtml.trim()) {
return {
ok: false,
@@ -114,7 +124,7 @@ export function checkSubCompositionUsability(
return {
ok: false,
reason: "unparsable",
detail: "the file's <template>/<body> contents could not be parsed as HTML",
detail: "the file's resolved contents could not be parsed as HTML",
};
}
@@ -127,8 +137,7 @@ export function checkSubCompositionUsability(
return {
ok: false,
reason: "no-composition-root",
detail:
"the file's <template>/<body> content has no element with a data-composition-id attribute",
detail: "the file's resolved content has no element with a data-composition-id attribute",
};
}
@@ -827,6 +827,27 @@ describe("detectRenderModeHints", () => {
).rejects.toThrow(/compositions\/does-not-exist\.html/);
});
it("compileForRender preserves a bare fragment's markup, sibling styles, and script", async () => {
const projectDir = makeSubCompProject(
"hf-fragment-subcomp-",
[{ id: "intro", src: "compositions/intro.html" }],
{
"intro.html": `<style>.fragment-title { color: rgb(12, 34, 56); }</style>
<div data-composition-id="intro" data-width="100" data-height="100"><div class="fragment-title">Bare fragment</div></div>
<script>window.__fragmentLoaded = true;</script>`,
},
);
try {
const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
const { document } = parseHTML(result.html);
expect(document.querySelector(".fragment-title")?.textContent).toBe("Bare fragment");
expect(result.html).toContain("rgb(12, 34, 56)");
expect(result.html).toContain("window.__fragmentLoaded = true");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("compileForRender succeeds when the sub-composition file is valid (happy path)", async () => {
const projectDir = makeSubCompProject(
"hf-valid-subcomp-",
@@ -163,7 +163,7 @@ class EmptyCompositionError extends Error {
`${problems.length} composition file${problems.length === 1 ? "" : "s"} referenced by ` +
`data-composition-src cannot be rendered:\n${lines.join("\n")}\n\n` +
"Check that each file referenced by data-composition-src contains valid HTML with a " +
"<template> or <body> containing a [data-composition-id] element. If a scene-authoring " +
"[data-composition-id] element in a <template>, <body>, or bare fragment. If a scene-authoring " +
"step is still running, wait for it to finish before referencing the file.",
);
this.name = "EmptyCompositionError";
@@ -54,6 +54,7 @@
"style-7-prod": 325,
"style-8-prod": 178,
"style-9-prod": 216,
"subcomp-bare-fragment": 5,
"sub-comp-id-selector": 10,
"sub-comp-t0": 18,
"sub-composition-video": 99,
@@ -0,0 +1,13 @@
{
"name": "Bare fragment sub-composition",
"description": "A sub-composition without html, body, or template wrappers renders its root and sibling linked CSS, inline styles, and GSAP script. The moving square catches dropped markup, styles, or animation; raw-fragment rejection fails compilation.",
"tags": ["sub-composition", "regression"],
"minPsnr": 40,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 24,
"workers": 1
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbe739daff8c969b5106ac0c95484556778604daf0d6f99ae1f99fd2fdf9d6ea
size 88423
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21635cf3545ee9c36568cf2b68c31c23ae1c5d03b9b645b926b73ee9e4999eb2
size 7693
@@ -0,0 +1,3 @@
.square {
background: #00d4aa;
}
@@ -0,0 +1,19 @@
<link rel="stylesheet" href="scene.css" />
<style>
.square {
position: absolute;
left: 20px;
top: 50px;
width: 80px;
height: 80px;
}
</style>
<div data-composition-id="scene" data-width="320" data-height="180" data-duration="2">
<div class="square"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines.scene = gsap
.timeline({ paused: true })
.to(".square", { x: 160, duration: 2, ease: "none" });
</script>
@@ -0,0 +1,35 @@
<!doctype html>
<html>
<head>
<style>
html,
body {
margin: 0;
background: #101820;
}
[data-composition-id="main"] {
background: #101820;
position: relative;
width: 320px;
height: 180px;
overflow: hidden;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.14.2/gsap.min.js"></script>
</head>
<body>
<div data-composition-id="main" data-width="320" data-height="180" data-duration="2">
<div
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="2"
style="position: absolute; inset: 0"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines.main = gsap.timeline({ paused: true }).to({}, { duration: 2 });
</script>
</body>
</html>