test: add regression fixture for sub-comp #ID selector scoping divergence

The producer inlining path strips the inner root element (taking innerHTML
when compId matches), losing the id attribute. The bundler path preserves it
via flattenInnerRoot + data-hf-authored-root-id. This causes #ID selectors
in sub-comp CSS and GSAP to fail silently during render while working in
preview.

Adds a minimal fixture with a sub-comp using #intro scope to catch this
divergence in future compiler changes.
This commit is contained in:
Miguel Ángel
2026-05-19 15:38:59 -04:00
parent 7354d61371
commit 60cdf8c66b
4 changed files with 270 additions and 0 deletions
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { inlineSubCompositions } from "./inlineSubCompositions";
/**
* Minimal sub-composition HTML that uses `#intro` as its CSS and GSAP scope.
* This is the pattern that breaks when the producer path strips the inner root.
*/
const SUB_COMP_HTML = `<template id="intro-template">
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
<div class="title" style="opacity:0;">HELLO WORLD</div>
<style>
#intro { position:relative; width:1920px; height:1080px; background:#111; }
#intro .title { font-size:120px; color:#fff; }
</style>
<script>
(function() {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.fromTo('#intro .title', { opacity:0 }, { opacity:1, duration:0.5 }, 0.2);
window.__timelines['intro'] = tl;
})();
</script>
</div>
</template>`;
function makeHostDocument(compId: string) {
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-id="${compId}" data-composition-src="intro.html"
data-start="0" data-duration="4" data-track-index="0"></div>
</div>
</body></html>`);
return document;
}
describe("inlineSubCompositions #ID selector scoping divergence", () => {
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The producer path takes innerHTML when compId matches, stripping the
// wrapper <div id="intro" ...>. The host element should NOT contain a
// child with id="intro" — the id attribute is lost.
const innerRootById = host.querySelector("#intro");
expect(innerRootById).toBeNull();
// The host itself still has data-composition-id="intro" (from the
// original markup), but no element inside has id="intro".
expect(host.getAttribute("data-composition-id")).toBe("intro");
// CSS was scoped: #intro selectors should be rewritten to use
// data-hf-authored-id attribute selector so they still resolve.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).not.toContain("#intro");
});
it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]`
// so that the selector resolves against the flattened structure.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title');
});
it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The wrapped script should contain the authored root id normalization
// logic so that runtime querySelector('#intro .title') maps to the
// data-hf-authored-id attribute selector.
const wrappedScript = result.scripts.join("\n");
expect(wrappedScript).toContain("__hfAuthoredRootId");
expect(wrappedScript).toContain('"intro"');
});
it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
// Simulate the bundler's flattenInnerRoot: clone the element, add
// data-hf-authored-id, strip timing attrs (simplified here).
function flattenInnerRoot(innerRoot: Element): Element {
const clone = innerRoot.cloneNode(true) as Element;
const authoredId = clone.getAttribute("id");
if (authoredId) {
clone.setAttribute("data-hf-authored-id", authoredId);
clone.removeAttribute("id");
}
clone.removeAttribute("data-start");
clone.removeAttribute("data-duration");
return clone;
}
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
flattenInnerRoot,
});
// With flattenInnerRoot, the inner root is preserved as a child of the
// host via outerHTML. The data-hf-authored-id attribute is present.
const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]');
expect(authoredRoot).not.toBeNull();
// CSS is still rewritten to use the attribute selector.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
});
/**
* Known divergence: the producer path strips the inner root element via
* innerHTML (line 307 of inlineSubCompositions.ts), losing the id attribute.
* The bundler path preserves it via flattenInnerRoot + data-hf-authored-id.
*
* Both paths rewrite CSS and GSAP selectors from `#intro` to
* `[data-hf-authored-id="intro"]`, but only the bundler path actually adds
* that attribute to an element. In the producer path, no element carries
* `data-hf-authored-id`, so the rewritten selectors match nothing.
*
* Workaround: use [data-composition-id="name"] as scope in sub-compositions
* instead of #name.
*
* Proper fix (follow-up): make the producer path add data-hf-authored-id
* to the host element when the inner root has an id attribute.
*/
it("documents the divergence: producer path lacks data-hf-authored-id element", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
// Producer path: no flattenInnerRoot
inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// After producer inlining, no element inside the host has
// data-hf-authored-id="intro". The rewritten CSS/GSAP selectors
// targeting [data-hf-authored-id="intro"] will match nothing.
const authoredIdElement = host.querySelector('[data-hf-authored-id="intro"]');
expect(authoredIdElement).toBeNull();
// The original #intro element is gone — innerHTML stripped it.
const introById = host.querySelector("#intro");
expect(introById).toBeNull();
// Only the host itself has data-composition-id="intro", which is the
// correct workaround scope to use.
expect(host.getAttribute("data-composition-id")).toBe("intro");
});
});