From 6e082d68e1e551f59d77f9a193d0210341f60ace Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 8 Oct 2026 00:40:16 -0700 Subject: [PATCH 1/9] fix(core): a data script in a sub-composition no longer stops every timeline A sub-composition's +`; + + async function bundleFilm(sceneExtra: string, options?: { sceneParts?: boolean }) { + const dir = makeTempProject({ + "index.html": ` + +
+
+
+
+ +`, + "compositions/intro.html": sceneFile("intro", ""), + "compositions/scene.html": sceneFile("scene", sceneExtra), + }); + try { + return parseHTML(await bundleToSingleHtml(dir, options)).document; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + const runnable = (document: Document) => + [...document.querySelectorAll("body script:not([src])")].filter((el) => + [null, AFTER_FONTS_SCRIPT_TYPE].includes(el.getAttribute("type")), + ); + const parses = (el: Element) => { + try { + new Function(el.textContent ?? ""); + return true; + } catch { + return false; + } + }; + const timelinesThatRun = (document: Document) => + ["main", "intro", "scene"].filter((id) => + runnable(document).some( + (el) => parses(el) && (el.textContent ?? "").includes(`__timelines.${id} =`), + ), + ); + + it("keeps a sub-composition's JSON data script readable and out of the JavaScript", async () => { + const document = await bundleFilm( + ``, + ); + const meta = document.querySelector('script[type="application/json"]'); + expect(meta?.getAttribute("id")).toBe("meta"); + expect(JSON.parse(meta?.textContent ?? "")).toEqual({ title: "x", beats: [1, 2] }); + expect(runnable(document).every(parses)).toBe(true); + expect(runnable(document).some((el) => el.textContent?.includes('"beats"'))).toBe(false); + expect(timelinesThatRun(document)).toEqual(["main", "intro", "scene"]); + }); + + it.each([false, true])( + "keeps a sub-composition script that does not parse apart, so the others still run (sceneParts %s)", + async (sceneParts) => { + const document = await bundleFilm(``, { sceneParts }); + const broken = runnable(document).filter((el) => !parses(el)); + expect(broken).toHaveLength(1); + expect(broken[0]!.textContent).toContain("window.broken"); + expect(timelinesThatRun(document)).toEqual(["main", "intro", "scene"]); + }, + ); +}); + describe("bundleToSingleHtml sceneParts", () => { const film = () => makeTempProject({ diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index b41893abcb3..769807bd453 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -744,10 +744,13 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { isPinned, )) { const mergedJs = joinJsChunks(members.map((el) => el.textContent || "")); + const stripped = mergedJs ? stripJsComments(mergedJs) : ""; + // Left unmerged, a script that does not parse fails alone instead of taking every timeline with it. + if (stripped === null) continue; for (const el of members) el.remove(); - if (!mergedJs) continue; + if (!stripped) continue; const inlineScript = document.createElement("script"); - inlineScript.textContent = escapeInlineScriptSource(stripJsCommentsParserSafe(mergedJs)); + inlineScript.textContent = escapeInlineScriptSource(stripped); if (anchor) anchor.before(inlineScript); else document.body.appendChild(inlineScript); } @@ -799,16 +802,23 @@ function joinJsChunks(chunks: string[]): string { .join("\n"); } -function stripJsCommentsParserSafe(source: string): string { - if (!source) return source; +/** Null when the source does not parse. */ +function stripJsComments(source: string): string | null { try { - const result = transformSync(source, { loader: "js", minify: false, legalComments: "none" }); - return result.code.trim(); + return transformSync(source, { + loader: "js", + minify: false, + legalComments: "none", + }).code.trim(); } catch { - return source; + return null; } } +function stripJsCommentsParserSafe(source: string): string { + return source ? (stripJsComments(source) ?? source) : source; +} + export interface BundleOptions { /** Project-relative HTML entry to bundle. Defaults to `index.html`. */ entryFile?: string; @@ -1261,12 +1271,15 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi } } for (const { scene, chunks } of scriptRuns) { - const script = document.createElement("script"); - if (scene) script.setAttribute(SCENE_PART_ATTR, scene); - script.textContent = joinJsChunks( - chunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk())), - ); - document.body.appendChild(script); + const texts = chunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk())); + const joined = joinJsChunks(texts); + // One script per chunk when the run does not parse, so one bad composition script fails alone. + for (const text of stripJsComments(joined) === null ? texts : [joined]) { + const script = document.createElement("script"); + if (scene) script.setAttribute(SCENE_PART_ATTR, scene); + script.textContent = text; + document.body.appendChild(script); + } } emitMountedModuleScripts(document, subCompResult.importMaps, subCompResult.moduleScripts); diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index fd3f15731bf..60ee920019b 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -43,7 +43,7 @@ import { import { enumerateNestedCompositionHosts, planCompositionAssembly, - EXTRACTED_COMPOSITION_ASSET_SELECTOR, + extractedCompositionAssets, } from "./compositionAssembly"; import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts"; @@ -610,8 +610,7 @@ export function inlineSubCompositions( // Inject content into the host element if (innerRoot) { innerRoot.setAttribute("data-composition-file", src); - for (const child of [...innerRoot.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)]) - child.remove(); + for (const child of extractedCompositionAssets(innerRoot)) child.remove(); if (flattenInnerRoot) { const prepared = flattenInnerRoot(innerRoot); if (!compId && scopeCompId) { @@ -635,8 +634,7 @@ export function inlineSubCompositions( } } } else { - for (const child of [...contentDoc.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)]) - child.remove(); + for (const child of extractedCompositionAssets(contentDoc)) child.remove(); // linkedom fragment parsing: when content is `
...
`, // the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML // to preserve the composition wrapper. diff --git a/packages/core/src/compiler/scriptRuns.ts b/packages/core/src/compiler/scriptRuns.ts index b2ee24b44f6..9aaa7a9efcb 100644 --- a/packages/core/src/compiler/scriptRuns.ts +++ b/packages/core/src/compiler/scriptRuns.ts @@ -1,34 +1,13 @@ +import { isJavaScriptType } from "./compositionAssembly"; + +export { isJavaScriptType }; + export interface InlineScriptRun { members: Element[]; /** First later script that executes on its own; the merged run must stay before it. Null: end of body. */ anchor: Element | null; } -// The HTML spec's JavaScript MIME type essence matches: a script with any of these types runs as classic. -const JAVASCRIPT_TYPES = new Set([ - "", - "application/ecmascript", - "application/javascript", - "application/x-ecmascript", - "application/x-javascript", - "text/ecmascript", - "text/javascript", - "text/javascript1.0", - "text/javascript1.1", - "text/javascript1.2", - "text/javascript1.3", - "text/javascript1.4", - "text/javascript1.5", - "text/jscript", - "text/livescript", - "text/x-ecmascript", - "text/x-javascript", -]); - -export function isJavaScriptType(el: Element): boolean { - return JAVASCRIPT_TYPES.has((el.getAttribute("type") || "").trim().toLowerCase()); -} - function isClassicInline(el: Element): boolean { return !el.hasAttribute("nomodule") && isJavaScriptType(el); } diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index 3f066cce840..4d9e32145d0 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -1503,6 +1503,31 @@ describe("loadCompositions inline templates", () => { expect(injectedScripts[0].textContent).toContain("inline template script"); }); + it("leaves a JSON data script in the mounted content instead of running it", async () => { + const template = document.createElement("template"); + template.id = "data-comp-template"; + template.innerHTML = ` +
+ + +
+ `; + document.body.appendChild(template); + const host = document.createElement("div"); + host.setAttribute("data-composition-id", "data-comp"); + document.body.appendChild(host); + + const { injectedScripts } = await loadFixture(); + + expect(injectedScripts.map((script) => script.textContent).join("\n")).not.toContain("title"); + expect(injectedScripts.some((script) => script.textContent?.includes("__dataCompRan"))).toBe( + true, + ); + const meta = host.querySelector("#meta"); + expect(meta?.getAttribute("type")).toBe("application/json"); + expect(JSON.parse(meta?.textContent ?? "")).toEqual({ title: "x" }); + }); + it("copies dimension attributes from template inner root to host", async () => { const template = document.createElement("template"); template.id = "dim-comp-template"; diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index 0f7814e0af0..b62304639f4 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -5,7 +5,7 @@ import { } from "../compiler/svgSelectorAliases"; import { planCompositionAssembly, - EXTRACTED_COMPOSITION_ASSET_SELECTOR, + extractedCompositionAssets, } from "../compiler/compositionAssembly"; import { scopeCssToComposition, @@ -225,7 +225,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(EXTRACTED_COMPOSITION_ASSET_SELECTOR))) { + for (const el of extractedCompositionAssets(node)) { el.remove(); } } From 04ed87c4114289def396f76b3c0d14d74d4e7d0d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 8 Oct 2026 01:27:50 -0700 Subject: [PATCH 2/9] fix(core): render and preview keep a broken composition script from stopping the rest The render path's script merge had the same gap as the bundler: one script that does not parse stopped every timeline. Both now share one parse check. A run left unmerged, and every scene-part script, is still escaped, so a string holding "" can no longer end the script early in preview. --- .../core/src/compiler/compositionAssembly.ts | 8 ++- .../core/src/compiler/htmlBundler.test.ts | 29 +++++++- packages/core/src/compiler/htmlBundler.ts | 23 +++++-- packages/core/src/compiler/index.ts | 3 +- packages/core/src/compiler/scriptRuns.ts | 2 - .../services/htmlCompiler.scriptOrder.test.ts | 69 +++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 11 ++- 7 files changed, 129 insertions(+), 16 deletions(-) diff --git a/packages/core/src/compiler/compositionAssembly.ts b/packages/core/src/compiler/compositionAssembly.ts index 3dfd2bc2c92..85d568df496 100644 --- a/packages/core/src/compiler/compositionAssembly.ts +++ b/packages/core/src/compiler/compositionAssembly.ts @@ -55,14 +55,16 @@ const JAVASCRIPT_TYPES = new Set([ "text/x-javascript", ]); +const scriptType = (el: AssemblyAttributed) => (el.getAttribute("type") || "").trim().toLowerCase(); + export function isJavaScriptType(el: AssemblyAttributed): boolean { - return JAVASCRIPT_TYPES.has((el.getAttribute("type") || "").trim().toLowerCase()); + return JAVASCRIPT_TYPES.has(scriptType(el)); } /** A data block (application/json, text/template, ...) never runs, so it stays in the content as authored. */ function isExecutableScript(el: AssemblyAttributed): boolean { - const type = (el.getAttribute("type") || "").trim().toLowerCase(); - return type === "module" || type === "importmap" || isJavaScriptType(el); + const type = scriptType(el); + return type === "module" || type === "importmap" || JAVASCRIPT_TYPES.has(type); } function executableScripts( diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index a7743f7f82b..1595d52a5b6 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -2877,7 +2877,11 @@ describe("bundleToSingleHtml composition scripts that are not JavaScript", () => `; - async function bundleFilm(sceneExtra: string, options?: { sceneParts?: boolean }) { + async function bundleFilm( + sceneExtra: string, + options?: { sceneParts?: boolean }, + files: Record = {}, + ) { const dir = makeTempProject({ "index.html": ` @@ -2889,9 +2893,11 @@ describe("bundleToSingleHtml composition scripts that are not JavaScript", () => `, "compositions/intro.html": sceneFile("intro", ""), "compositions/scene.html": sceneFile("scene", sceneExtra), + ...files, }); try { - return parseHTML(await bundleToSingleHtml(dir, options)).document; + const html = await bundleToSingleHtml(dir, options); + return Object.assign(parseHTML(html).document, { html }); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -2938,6 +2944,25 @@ describe("bundleToSingleHtml composition scripts that are not JavaScript", () => expect(timelinesThatRun(document)).toEqual(["main", "intro", "scene"]); }, ); + + it.each([ + [false, ``], + [true, ``], + [true, ""], + ])( + "escapes a composition script it does not merge (sceneParts %s, extra %j)", + async (sceneParts, extra) => { + const document = await bundleFilm( + `${extra}`, + { sceneParts }, + { "compositions/lib.js": 'window.tag = "LEAK