From 3701bb9eebced54a77a90c95675d6646863299a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 27 Apr 2026 15:49:36 -0400 Subject: [PATCH] fix: flatten inlined composition roots --- .../src/compiler/compositionScoping.test.ts | 18 +++++++- .../core/src/compiler/compositionScoping.ts | 43 ++++++++++++++++--- .../core/src/compiler/htmlBundler.test.ts | 20 +++++++-- packages/core/src/compiler/htmlBundler.ts | 11 ++--- .../src/runtime/compositionLoader.test.ts | 15 ++++++- .../core/src/runtime/compositionLoader.ts | 9 ++-- .../src/services/htmlCompiler.test.ts | 18 ++++++-- .../producer/src/services/htmlCompiler.ts | 12 +----- 8 files changed, 105 insertions(+), 41 deletions(-) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index b0c25f3ec..338c1ca99 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -41,15 +41,26 @@ body { margin: 0; } expect(wrapped).not.toContain("requestAnimationFrame"); }); + it("normalizes root timing attributes when scoping selectors", () => { + const scoped = scopeCssToComposition( + '[data-composition-id="scene"][data-start="0"] .title { opacity: 0; }', + "scene", + ); + + expect(scoped).toContain('[data-composition-id="scene"] .title { opacity: 0; }'); + expect(scoped).not.toContain('[data-start="0"]'); + }); + it("executes document and GSAP selectors inside the composition root", () => { const { document } = parseHTML(` -

Scene

+

Scene

Other

`); const gsapTargets: string[][] = []; const fakeWindow = { document, __selectedTitle: "", + __selectedRootTitle: "", __timelines: {}, gsap: { timeline: () => ({ @@ -64,7 +75,9 @@ body { margin: 0; } ` const tl = gsap.timeline({ paused: true }); tl.to('.title', { opacity: 1 }); +tl.to('[data-composition-id="scene"][data-start="0"] .title', { opacity: 1 }); window.__selectedTitle = document.querySelector('.title')?.textContent || ''; +window.__selectedRootTitle = document.querySelector('[data-composition-id="scene"][data-start="0"] .title')?.textContent || ''; window.__timelines.scene = tl; `, "scene", @@ -73,6 +86,7 @@ window.__timelines.scene = tl; new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap); expect(fakeWindow.__selectedTitle).toBe("Scene"); - expect(gsapTargets).toEqual([["Scene"]]); + expect(fakeWindow.__selectedRootTitle).toBe("Scene"); + expect(gsapTargets).toEqual([["Scene"], ["Scene"]]); }); }); diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 6c6dd164d..817bcfe03 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -138,18 +138,36 @@ function splitSelectorList(selectorText: string): string[] { } function scopeSelector(selector: string, scope: string, compositionId: string): string { - const trimmed = selector.trim(); + const selectorWithoutRootTiming = normalizeCompositionRootSelector( + selector, + scope, + compositionId, + ); + const trimmed = selectorWithoutRootTiming.trim(); if (!trimmed) return selector; if (/^(html|body|:root|\*)$/i.test(trimmed)) return selector; const compositionIdPattern = new RegExp( `data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1`, ); - if (compositionIdPattern.test(trimmed)) return selector; - const leading = selector.match(/^\s*/)?.[0] ?? ""; - const trailing = selector.match(/\s*$/)?.[0] ?? ""; + if (compositionIdPattern.test(trimmed)) return selectorWithoutRootTiming; + const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? ""; + const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? ""; return `${leading}${scope} ${trimmed}${trailing}`; } +function normalizeCompositionRootSelector( + selector: string, + scope: string, + compositionId: string, +): string { + const quotedCompId = escapeRegExp(compositionId); + const compAttr = String.raw`\[\s*data-composition-id\s*=\s*(?:"${quotedCompId}"|'${quotedCompId}')\s*\]`; + const timingAttr = String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`; + return selector + .replace(new RegExp(`${compAttr}(?:${timingAttr})+`, "g"), scope) + .replace(new RegExp(`(?:${timingAttr})+${compAttr}`, "g"), scope); +} + function scopeSelectorList(selectorText: string, scope: string, compositionId: string): string { return splitSelectorList(selectorText) .map((selector) => scopeSelector(selector, scope, compositionId)) @@ -213,6 +231,13 @@ export function wrapScopedCompositionScript( ): string { const compositionIdLiteral = JSON.stringify(compositionId); const errorLabelLiteral = JSON.stringify(errorLabel); + const escapedCompositionId = escapeRegExp(compositionId); + const rootSelectorPatternLiteral = JSON.stringify( + String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`, + ); + const timingSelectorPatternLiteral = JSON.stringify( + String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`, + ); return `(function(){ var __hfCompId = ${compositionIdLiteral}; var __hfErrorLabel = ${errorLabelLiteral}; @@ -223,6 +248,14 @@ export function wrapScopedCompositionScript( ? '[data-composition-id="' + __hfEscapeAttr(__hfCompId) + '"]' : ""; var __hfRoot = null; + var __hfRootSelectorPattern = ${rootSelectorPatternLiteral}; + var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral}; + var __hfNormalizeSelector = function(selector) { + if (!__hfCompId || typeof selector !== "string") return selector; + return selector + .replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector) + .replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector); + }; var __hfFindRoot = function() { if (!__hfRoot && __hfRootSelector) { __hfRoot = window.document.querySelector(__hfRootSelector); @@ -238,7 +271,7 @@ export function wrapScopedCompositionScript( if (!root || typeof selector !== "string") { return window.document.querySelectorAll(selector); } - return Array.prototype.filter.call(window.document.querySelectorAll(selector), function(node) { + return Array.prototype.filter.call(window.document.querySelectorAll(__hfNormalizeSelector(selector)), function(node) { return __hfContains(node); }); }; diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index de3646f65..197c31323 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { parseHTML } from "linkedom"; import { describe, it, expect } from "vitest"; import { bundleToSingleHtml } from "./htmlBundler"; @@ -196,7 +197,7 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain("Sized content"); }); - it("preserves the sub-composition root when inlining external compositions", async () => { + it("flattens the sub-composition root onto the host when inlining external compositions", async () => { const dir = makeTempProject({ "index.html": ` @@ -225,9 +226,20 @@ describe("bundleToSingleHtml", () => { const bundled = await bundleToSingleHtml(dir); - expect(bundled).toContain('id="scene-host"'); - expect(bundled).toContain('data-composition-id="scene" data-start="0"'); - expect(bundled).toContain('[data-composition-id="scene"][data-start="0"]'); + const { document } = parseHTML(bundled); + const host = document.querySelector("#scene-host"); + + expect(host?.getAttribute("data-composition-id")).toBe("scene"); + expect(host?.getAttribute("data-start")).toBe("intro"); + expect(host?.getAttribute("data-width")).toBe("1920"); + expect(host?.querySelector(".title")?.textContent).toBe("Scene"); + expect( + Array.from(host?.children ?? []).some( + (child) => child.getAttribute("data-composition-id") === "scene", + ), + ).toBe(false); + expect(bundled).toContain('[data-composition-id="scene"] .title'); + expect(bundled).toContain("__hfNormalizeSelector"); }); it("scopes external sub-composition styles and classic scripts", async () => { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 1533cdd43..89fb37092 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -3,11 +3,7 @@ import { join, resolve, isAbsolute, sep } from "path"; import { parseHTML } from "linkedom"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; -import { - rewriteAssetPaths, - rewriteCssAssetUrls, - rewriteInlineStyleAssetUrls, -} from "./rewriteSubCompPaths"; +import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; @@ -514,7 +510,7 @@ export async function bundleToSingleHtml( if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW); if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH); for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove(); - hostEl.innerHTML = innerRoot.outerHTML || ""; + hostEl.innerHTML = innerRoot.innerHTML || ""; } else { for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove(); hostEl.innerHTML = contentDoc.body.innerHTML || ""; @@ -578,8 +574,7 @@ export async function bundleToSingleHtml( if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW); if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH); - // Preserve the inner composition root so bundled previews match the runtime loader. - host.innerHTML = innerRoot.outerHTML || ""; + host.innerHTML = innerRoot.innerHTML || ""; } else { // No matching inner root — inject all template content directly for (const styleEl of [...innerDoc.querySelectorAll("style")]) { diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index ef716901b..6bcc2bcb0 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -51,11 +51,16 @@ describe("loadExternalCompositions", () => { await loadExternalCompositions({ ...defaultParams }); const mountedParagraph = host.querySelector("p"); - const innerRoot = host.firstElementChild; expect(mountedParagraph).toBeTruthy(); expect(mountedParagraph?.textContent).toBe("Hello World"); - expect(innerRoot?.getAttribute("data-composition-id")).toBe("scene-1"); + expect(host.getAttribute("data-width")).toBe("1920"); + expect(host.getAttribute("data-height")).toBe("1080"); + expect( + Array.from(host.children).some( + (child) => child.getAttribute("data-composition-id") === "scene-1", + ), + ).toBe(false); }); it("injects styles into document head", async () => { @@ -230,6 +235,12 @@ describe("loadExternalCompositions", () => { expect(injectedStyles[0]?.textContent).toContain('[data-composition-id="scene"] .title'); expect(injectedScripts[0]?.textContent).toContain('var __hfCompId = "scene";'); expect(injectedScripts[0]?.textContent).toContain("new Proxy(window.document"); + expect(host.querySelector(".title")?.textContent).toBe("Scene"); + expect( + Array.from(host.children).some( + (child) => child.getAttribute("data-composition-id") === "scene", + ), + ).toBe(false); }); it("handles multiple compositions in parallel", async () => { diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index 71d83cb87..df484f108 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -198,16 +198,13 @@ async function mountCompositionContent(params: { const heightRaw = innerRoot.getAttribute("data-height"); const widthPx = params.parseDimensionPx(widthRaw); const heightPx = params.parseDimensionPx(heightRaw); - imported.style.position = "relative"; - imported.style.width = widthPx || "100%"; - imported.style.height = heightPx || "100%"; - if (widthPx) imported.style.setProperty("--comp-width", widthPx); - if (heightPx) imported.style.setProperty("--comp-height", heightPx); if (widthRaw) params.host.setAttribute("data-width", widthRaw); if (heightRaw) params.host.setAttribute("data-height", heightRaw); if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx; if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx; - params.host.appendChild(imported); + while (imported.firstChild) { + params.host.appendChild(imported.firstChild); + } } else if (params.hasTemplate) { params.host.appendChild(document.importNode(contentNode, true)); } else { diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 73d2d02ee..6b62c29e7 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, mock, beforeAll } from "bun:test"; import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { parseHTML } from "linkedom"; import { collectExternalAssets, compileForRender, @@ -598,7 +599,7 @@ describe("template-wrapped sub-composition media offsets", () => { }); }); - it("preserves the sub-composition root in compiled render HTML", async () => { + it("flattens the sub-composition root onto the host in compiled render HTML", async () => { const { projectDir, indexPath } = writeTemplateWrappedProject( 'data-start="20" data-duration="6" data-width="640" data-height="360"', 'data-start="1.5" data-duration="4"', @@ -606,9 +607,20 @@ describe("template-wrapped sub-composition media offsets", () => { const compiled = await compileForRender(projectDir, indexPath, projectDir); - expect(compiled.html).toContain('id="scene-host"'); - expect(compiled.html).toContain('data-composition-id="scene" data-start="0"'); + const { document } = parseHTML(compiled.html); + const host = document.querySelector("#scene-host"); + + expect(host?.getAttribute("data-composition-id")).toBe("scene"); + expect(host?.getAttribute("data-start")).toBe("20"); + expect(host?.getAttribute("data-width")).toBe("640"); + expect(host?.querySelector(".title")?.textContent).toBe("Scene"); + expect( + Array.from(host?.children ?? []).some( + (child) => child.getAttribute("data-composition-id") === "scene", + ), + ).toBe(false); expect(compiled.html).toContain('[data-composition-id="scene"] .title'); expect(compiled.html).toContain("new Proxy(window.document"); + expect(compiled.html).toContain("__hfNormalizeSelector"); }); }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index b3a678d24..cca2f9cbf 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -655,7 +655,7 @@ function inlineSubCompositions( if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW); if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH); innerRoot.querySelectorAll("style, script").forEach((el) => el.remove()); - host.innerHTML = innerRoot.outerHTML || ""; + host.innerHTML = innerRoot.innerHTML || ""; } else { contentDoc.querySelectorAll("style, script").forEach((el) => el.remove()); host.innerHTML = contentDoc.toString(); @@ -663,16 +663,6 @@ function inlineSubCompositions( host.removeAttribute("data-composition-src"); - // Propagate data-start from the host element to the inserted inner composition - // node so runtime timeline nesting resolves the correct start offset. - const hostDataStart = host.getAttribute("data-start"); - if (hostDataStart != null) { - const innerComp = host.querySelector("[data-composition-id]"); - if (innerComp && !innerComp.getAttribute("data-start")) { - innerComp.setAttribute("data-start", hostDataStart); - } - } - // Set explicit pixel dimensions on the host element so children using // width/height: 100% resolve correctly. The runtime does this // automatically but compiled HTML needs it inline.