Skip to content

Commit 8802c3f

Browse files
fix(core): actionable error for empty sub-composition HTML in compile (#1364)
## Problem The most common render failure in recent reports is: ``` Cannot destructure property 'firstElementChild' of 'documentElement' as it is null. ``` It appears when a `data-composition-src` file resolves to empty or unparsable HTML, and started showing up after the render pipeline change in 0.6.73. ## Root cause When a sub-composition file is empty or unparsable, linkedom's `parseHTML` returns a document with a null `documentElement`, and the shared inliner (`packages/core/src/compiler/inlineSubCompositions.ts`) dereferences `.body`/`.head` on it, crashing inside linkedom internals with the cryptic destructure error instead of telling the user what's wrong. ## Fix Guard the resolved sub-composition HTML and the extracted content HTML in the shared inliner: empty or unparsable input now fails with an actionable error naming the offending file. ## Testing - New tests in core and producer reproducing the empty sub-composition case (previously crashed with the destructure error, now throws the actionable message). - `bun run build` green, all tests pass in the changed test files.
1 parent a0ee972 commit 8802c3f

3 files changed

Lines changed: 65 additions & 0 deletions

File tree

packages/core/src/compiler/inlineSubCompositions.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ function makeHostDocument(compId: string) {
3838
}
3939

4040
describe("inlineSubCompositions – #ID selector scoping divergence", () => {
41+
it("throws an actionable error when a resolved sub-composition file is empty", () => {
42+
const document = makeHostDocument("intro");
43+
const host = document.querySelector('[data-composition-src="intro.html"]')!;
44+
45+
expect(() =>
46+
inlineSubCompositions(document, [host], {
47+
resolveHtml: () => "",
48+
parseHtml: (html) => parseHTML(html).document,
49+
}),
50+
).toThrow(
51+
"Composition HTML is empty or could not be parsed: intro.html. Check that the file referenced by data-composition-src contains valid HTML.",
52+
);
53+
});
54+
4155
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
4256
const document = makeHostDocument("intro");
4357
const host = document.querySelector('[data-composition-src="intro.html"]')!;

packages/core/src/compiler/inlineSubCompositions.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,24 @@ function defaultBuildScopeSelector(compId: string): string {
124124
return `[data-composition-id="${escaped}"]`;
125125
}
126126

127+
function emptyCompositionHtmlError(src: string): Error {
128+
return new Error(
129+
`Composition HTML is empty or could not be parsed: ${src}. Check that the file referenced by data-composition-src contains valid HTML.`,
130+
);
131+
}
132+
133+
function assertNonEmptyCompositionHtml(html: string, src: string): void {
134+
if (!html.trim()) {
135+
throw emptyCompositionHtmlError(src);
136+
}
137+
}
138+
139+
function assertParsedCompositionDocument(doc: Document, src: string): void {
140+
if (!doc.documentElement) {
141+
throw emptyCompositionHtmlError(src);
142+
}
143+
}
144+
127145
// ---------------------------------------------------------------------------
128146
// Core implementation
129147
// ---------------------------------------------------------------------------
@@ -182,7 +200,9 @@ export function inlineSubCompositions(
182200
continue;
183201
}
184202

203+
assertNonEmptyCompositionHtml(compHtml, src);
185204
const compDoc = parseHtml(compHtml);
205+
assertParsedCompositionDocument(compDoc, src);
186206

187207
// Determine composition IDs
188208
let compId: string | null;
@@ -199,7 +219,9 @@ export function inlineSubCompositions(
199219
// Find content: prefer <template>, fall back to <body>
200220
const contentRoot = compDoc.querySelector("template");
201221
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
222+
assertNonEmptyCompositionHtml(contentHtml, src);
202223
const contentDoc = parseHtml(contentHtml);
224+
assertParsedCompositionDocument(contentDoc, src);
203225

204226
// Find the inner composition root
205227
const innerRoot = compId

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,35 @@ describe("detectRenderModeHints", () => {
491491
globalThis.fetch = originalFetch;
492492
}
493493
});
494+
495+
it("compileForRender reports empty sub-composition HTML with an actionable error", async () => {
496+
const projectDir = mkdtempSync(join(tmpdir(), "hf-empty-subcomp-"));
497+
const compositionsDir = join(projectDir, "compositions");
498+
mkdirSync(compositionsDir, { recursive: true });
499+
writeFileSync(
500+
join(projectDir, "index.html"),
501+
`<!DOCTYPE html>
502+
<html>
503+
<head></head>
504+
<body>
505+
<div data-composition-id="main" data-width="100" data-height="100" data-start="0" data-duration="1">
506+
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="1"></div>
507+
</div>
508+
<script>
509+
window.__timelines = window.__timelines || {};
510+
window.__timelines.main = { duration: function() { return 1; } };
511+
</script>
512+
</body>
513+
</html>`,
514+
);
515+
writeFileSync(join(compositionsDir, "intro.html"), "");
516+
517+
await expect(
518+
compileForRender(projectDir, join(projectDir, "index.html"), projectDir),
519+
).rejects.toThrow(
520+
"Composition HTML is empty or could not be parsed: compositions/intro.html. Check that the file referenced by data-composition-src contains valid HTML.",
521+
);
522+
});
494523
});
495524

496525
describe("detectShaderTransitionUsage", () => {

0 commit comments

Comments
 (0)