From 60cdf8c66b19d785991ee14a474109a82b0d6edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 19 May 2026 15:38:59 -0400 Subject: [PATCH] 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. --- .../src/compiler/compositionScoping.test.ts | 57 ++++++ .../compiler/inlineSubCompositions.test.ts | 172 ++++++++++++++++++ .../sub-comp-id-selector/intro.html | 20 ++ .../sub-comp-id-selector/root.html | 21 +++ 4 files changed, 270 insertions(+) create mode 100644 packages/core/src/compiler/inlineSubCompositions.test.ts create mode 100644 packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html create mode 100644 packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 6d7407f53..3af882f28 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -496,4 +496,61 @@ window.__afterTimeline = window.__timelines.scene; expect(fakeWindow.__afterTimeline).toBe("updated"); expect(errorSpy).not.toHaveBeenCalled(); }); + + it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => { + const scoped = scopeCssToComposition( + `#intro { background: #111; } +#intro .title { font-size: 120px; color: #fff; }`, + "intro", + undefined, + "intro", + ); + + // #intro should become [data-hf-authored-id="intro"] + expect(scoped).toContain('[data-hf-authored-id="intro"]'); + expect(scoped).toContain('[data-hf-authored-id="intro"] .title'); + // Raw #intro selectors should be gone + expect(scoped).not.toMatch(/#intro\b/); + }); + + it("wraps scripts with authored root id normalization for #id GSAP selectors", () => { + const { document } = parseHTML(` +
+
+
HELLO
+
+
+ `); + const gsapTargets: string[][] = []; + const fakeWindow = { + document, + __timelines: {}, + gsap: { + timeline: () => ({ + fromTo(targets: Element[], _from: unknown, _to: unknown) { + gsapTargets.push(Array.from(targets).map((t) => t.textContent || "")); + return this; + }, + }), + }, + }; + const wrapped = wrapScopedCompositionScript( + ` +var tl = gsap.timeline({ paused: true }); +tl.fromTo('#intro .title', { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.2); +window.__timelines['intro'] = tl; +`, + "intro", + "[HyperFrames] composition script error:", + undefined, + "intro", + "intro", + ); + + new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap); + + // The scoped script should resolve '#intro .title' against the + // data-hf-authored-id="intro" element, finding the .title child. + expect(gsapTargets).toEqual([["HELLO"]]); + }); }); diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts new file mode 100644 index 000000000..9dfd9f902 --- /dev/null +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -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 = ``; + +function makeHostDocument(compId: string) { + const { document } = parseHTML(` + +
+
+
+`); + 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
. 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"); + }); +}); diff --git a/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html new file mode 100644 index 000000000..91b50b4df --- /dev/null +++ b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html @@ -0,0 +1,20 @@ + diff --git a/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html new file mode 100644 index 000000000..b2a02b256 --- /dev/null +++ b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html @@ -0,0 +1,21 @@ + + + + + + + + +
+
+
+ + +