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.
This commit is contained in:
Miguel Ángel
2026-06-11 23:40:11 -04:00
committed by GitHub
parent a0ee97210b
commit 8802c3fdf8
3 changed files with 65 additions and 0 deletions
@@ -38,6 +38,20 @@ function makeHostDocument(compId: string) {
}
describe("inlineSubCompositions #ID selector scoping divergence", () => {
it("throws an actionable error when a resolved sub-composition file is empty", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
expect(() =>
inlineSubCompositions(document, [host], {
resolveHtml: () => "",
parseHtml: (html) => parseHTML(html).document,
}),
).toThrow(
"Composition HTML is empty or could not be parsed: intro.html. Check that the file referenced by data-composition-src contains valid HTML.",
);
});
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
@@ -124,6 +124,24 @@ function defaultBuildScopeSelector(compId: string): string {
return `[data-composition-id="${escaped}"]`;
}
function emptyCompositionHtmlError(src: string): Error {
return new Error(
`Composition HTML is empty or could not be parsed: ${src}. Check that the file referenced by data-composition-src contains valid HTML.`,
);
}
function assertNonEmptyCompositionHtml(html: string, src: string): void {
if (!html.trim()) {
throw emptyCompositionHtmlError(src);
}
}
function assertParsedCompositionDocument(doc: Document, src: string): void {
if (!doc.documentElement) {
throw emptyCompositionHtmlError(src);
}
}
// ---------------------------------------------------------------------------
// Core implementation
// ---------------------------------------------------------------------------
@@ -182,7 +200,9 @@ export function inlineSubCompositions(
continue;
}
assertNonEmptyCompositionHtml(compHtml, src);
const compDoc = parseHtml(compHtml);
assertParsedCompositionDocument(compDoc, src);
// Determine composition IDs
let compId: string | null;
@@ -199,7 +219,9 @@ export function inlineSubCompositions(
// Find content: prefer <template>, fall back to <body>
const contentRoot = compDoc.querySelector("template");
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
assertNonEmptyCompositionHtml(contentHtml, src);
const contentDoc = parseHtml(contentHtml);
assertParsedCompositionDocument(contentDoc, src);
// Find the inner composition root
const innerRoot = compId