From 8802c3fdf8c8385613d4662e70ae807e50be8fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 11 Jun 2026 23:40:11 -0400 Subject: [PATCH] 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. --- .../compiler/inlineSubCompositions.test.ts | 14 +++++++++ .../src/compiler/inlineSubCompositions.ts | 22 ++++++++++++++ .../src/services/htmlCompiler.test.ts | 29 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts index c51e4724d..452d1e8e8 100644 --- a/packages/core/src/compiler/inlineSubCompositions.test.ts +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -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"]')!; diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 00f09f268..a48e25ff8 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -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