From 872dd833817b449ac13e8a7dbb840fe89cedd852 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 1 Jul 2026 19:35:30 -0700 Subject: [PATCH 1/7] fix(producer): preserve sub-composition root wrapper at render time The render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview. Pass flattenInnerRoot (the same prepareFlattenedInnerRoot used by the preview bundler) into the producer's inlineSubCompositions call so the render-time DOM shape matches preview: the authored root survives as a child of the host. --- .../src/services/htmlCompiler.test.ts | 6 ++++- .../producer/src/services/htmlCompiler.ts | 24 ++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 9faee20d03..ca2881ebe1 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -903,7 +903,11 @@ describe("template-wrapped sub-composition media offsets", () => { const host = document.querySelector("#scene-host"); expect(host?.getAttribute("data-composition-id")).toBeNull(); - expect(host?.querySelector('[data-composition-id="scene"] .title')?.textContent).toBe("Scene"); + // The flattened inner root strips data-composition-id (same as the bundler's + // prepareFlattenedInnerRoot), so an anonymous host's content is only reachable + // via the preserved wrapper, not a [data-composition-id="scene"] selector — + // this matches preview parity, not a producer-specific contract. + expect(host?.querySelector("[data-hf-inner-root] .title")?.textContent).toBe("Scene"); expect(compiled.html).toContain('var __hfCompId = "scene";'); }); }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 4ef656bf4f..b2eead2299 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -23,7 +23,10 @@ import { type ResolvedDuration, type UnresolvedElement, } from "@hyperframes/core"; -import { inlineSubCompositions as inlineSubCompositionsShared } from "@hyperframes/core/compiler"; +import { + inlineSubCompositions as inlineSubCompositionsShared, + prepareFlattenedInnerRoot, +} from "@hyperframes/core/compiler"; import { checkSubCompositionUsability, type ParsableDocumentLike, @@ -745,7 +748,12 @@ function inlineSubCompositions( }, parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document, scriptErrorLabel: "[Compiler] Composition script failed", - compoundAuthoredRoot: true, + // Preserve the authored root wrapper as a child of the host, matching + // the preview/runtime shape (compositionLoader's prepareFlattenedInnerRoot). + // Without this, the wrapper element (and its class/id) is discarded and + // any CSS anchored on it — `.wrapper-class .title`, `#wrapper-id` — is + // dead at render time even though it works in preview. + flattenInnerRoot: prepareFlattenedInnerRoot as (innerRoot: Element) => Element, onMissingComposition: (srcPath: string, reason?: string) => { // In the render path this is normally unreachable — compileForRender // calls assertSubCompositionsUsable() before any of this runs, so a @@ -758,18 +766,6 @@ function inlineSubCompositions( }, ); - // Set data-hf-authored-id on host elements so the scoped script proxy - // can rewrite #id selectors (e.g. #us-map → [data-hf-authored-id="us-map"]). - // Unlike flattenInnerRoot (which changes DOM structure and breaks baselines), - // this preserves the existing innerHTML-based inlining while enabling the - // authored-id selector contract. - for (const hostEl of hosts) { - const compId = hostEl.getAttribute("data-composition-id"); - if (compId && !hostEl.getAttribute("data-hf-authored-id")) { - hostEl.setAttribute("data-hf-authored-id", compId); - } - } - // Producer-specific: set explicit pixel dimensions on host elements so // children using width/height: 100% resolve correctly. The runtime does // this automatically but compiled HTML needs it inline. From 0634d6780a2a441f3d465f7977d331d83c73a2a5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 1 Jul 2026 20:40:32 -0700 Subject: [PATCH 2/7] fix(core): preserve root box styling on flattened sub-composition wrappers Compositions that style their own box via the bare `[data-composition-id="X"]` selector (e.g. `display: flex` to center their children) rely on that rule landing on whatever element actually parents their content. Once flattenInnerRoot preserves the authored root as a wrapper below the host (data-hf-inner-root), the bare root selector was only ever rewritten onto the host, one level too high, so the composition's own children lost their flex or grid layout context entirely. Rewrite a bare root composition-id selector to a compound-OR targeting both the host and its data-hf-inner-root descendant, so the box styling reaches whichever element actually holds the composition's content. Selectors with a descendant part (e.g. `[data-composition-id="X"] .title`) are unaffected, since a plain descendant combinator already matches at any depth. --- .../src/compiler/compositionScoping.test.ts | 26 +++++++++++++++++++ .../core/src/compiler/compositionScoping.ts | 11 ++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 5d806ccdc6..e54443d5ee 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -609,6 +609,32 @@ window.__afterTimeline = window.__timelines.scene; expect(scoped).not.toMatch(/#intro\b/); }); + it("rewrites a bare root [data-composition-id] box selector to a compound-OR with [data-hf-inner-root]", () => { + // A composition styling its own box (e.g. `display:flex` to center its + // children) via the bare composition-id selector. After flattenInnerRoot + // preserves the authored root as a wrapper below the host, that wrapper + // (marked data-hf-inner-root) is what actually parents the real children, + // so the box styling must reach it too, not just the host. + const scoped = scopeCssToComposition( + '[data-composition-id="captions"] { display: flex; justify-content: center; }', + "captions", + ); + + expect(scoped).toContain( + '[data-composition-id="captions"], [data-composition-id="captions"] [data-hf-inner-root]', + ); + }); + + it("leaves root-plus-descendant [data-composition-id] selectors as a plain scope prefix", () => { + const scoped = scopeCssToComposition( + '[data-composition-id="captions"] .title { color: red; }', + "captions", + ); + + expect(scoped).toContain('[data-composition-id="captions"] .title'); + expect(scoped).not.toContain("data-hf-inner-root"); + }); + it('does not rewrite [id="intro"] attribute selectors', () => { // The function only targets #intro hash selectors, not [id="intro"] attribute selectors const result = scopeCssToComposition( diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 8ec81fc2b6..71225d9e5f 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -1,6 +1,7 @@ import postcss, { type AtRule, type Node, type Rule } from "postcss"; const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id"; +const INNER_ROOT_ATTR = "data-hf-inner-root"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -117,6 +118,16 @@ function scopeSelector( "g", ); if (compositionIdPattern.test(trimmed)) { + const isRootBoxSelector = trimmed.replace(compositionIdPattern, "").trim() === ""; + if (isRootBoxSelector) { + // A bare root selector styles the composition's own box (flex/grid/ + // position). When flattenInnerRoot preserves the authored root as a + // wrapper below `scope` (see prepareFlattenedInnerRoot), that wrapper + // is the element real children are laid out in, not `scope` itself. + // Target both so the box styling still reaches whichever one holds + // the composition's actual content. + return `${scope}, ${scope} [${INNER_ROOT_ATTR}]`; + } return selectorWithoutRootTiming.replace(compositionIdPattern, scope); } const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? ""; From 4da032281ea0c2377d2242c3147ada02451d9456 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 1 Jul 2026 22:35:57 -0700 Subject: [PATCH 3/7] fix(core): target exactly one of host or wrapper for root box styling The previous fix rewrote a bare root [data-composition-id] box selector to match both the host and the flattened wrapper. That double-applies any additive property (padding, margin, a non-zero transform): the wrapper is nested inside the host, so a rule like `padding-top: 200px` shifted content down twice instead of once, visible as overlapping elements in compositions that combine flex alignment with an offset (e.g. a captions overlay using `align-items: flex-start; padding-top: 200px`). Use :has()/:not() to target exactly one match: the wrapper when it exists (the flattened case), or the host when it doesn't (the documented non-flattened fallback for callers that omit flattenInnerRoot). --- .../src/compiler/compositionScoping.test.ts | 51 ++++++++++++++++--- .../core/src/compiler/compositionScoping.ts | 14 ++--- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index e54443d5ee..df3ce1d595 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -609,22 +609,61 @@ window.__afterTimeline = window.__timelines.scene; expect(scoped).not.toMatch(/#intro\b/); }); - it("rewrites a bare root [data-composition-id] box selector to a compound-OR with [data-hf-inner-root]", () => { + it("rewrites a bare root [data-composition-id] box selector to target exactly one of host or wrapper", () => { // A composition styling its own box (e.g. `display:flex` to center its - // children) via the bare composition-id selector. After flattenInnerRoot - // preserves the authored root as a wrapper below the host, that wrapper - // (marked data-hf-inner-root) is what actually parents the real children, - // so the box styling must reach it too, not just the host. + // children, or `padding` to offset it) via the bare composition-id + // selector. After flattenInnerRoot preserves the authored root as a + // wrapper below the host, that wrapper (marked data-hf-inner-root) is + // what actually parents the real children, so the box styling must land + // there instead of the host. It must land on exactly one of the two: + // targeting both would apply an additive property like `padding` twice, + // since the wrapper is nested inside the host. const scoped = scopeCssToComposition( '[data-composition-id="captions"] { display: flex; justify-content: center; }', "captions", ); expect(scoped).toContain( - '[data-composition-id="captions"], [data-composition-id="captions"] [data-hf-inner-root]', + '[data-composition-id="captions"]:not(:has([data-hf-inner-root])), ' + + '[data-composition-id="captions"] > [data-hf-inner-root]', ); }); + it("matches exactly the wrapper (not the host too) when both exist in the flattened DOM shape", () => { + // Regression test: an earlier version of this fix targeted both the host + // and the wrapper (a plain OR), which doubles any additive property + // (e.g. padding-top) since the wrapper is nested inside the host. + const scoped = scopeCssToComposition( + '[data-composition-id="captions"] { padding-top: 200px; }', + "captions", + ); + const ruleMatch = scoped.match(/([^{]+)\{/); + const selectorText = ruleMatch?.[1]?.trim(); + if (!selectorText) throw new Error("expected a CSS rule to be produced"); + + const { document } = parseHTML( + '
' + + '
' + + "
", + ); + const matches = [...document.querySelectorAll(selectorText)]; + expect(matches.map((el) => el.id)).toEqual(["wrapper"]); + }); + + it("matches the host when no wrapper is present (non-flattened fallback)", () => { + const scoped = scopeCssToComposition( + '[data-composition-id="captions"] { padding-top: 200px; }', + "captions", + ); + const ruleMatch = scoped.match(/([^{]+)\{/); + const selectorText = ruleMatch?.[1]?.trim(); + if (!selectorText) throw new Error("expected a CSS rule to be produced"); + + const { document } = parseHTML('
'); + const matches = [...document.querySelectorAll(selectorText)]; + expect(matches.map((el) => el.id)).toEqual(["host"]); + }); + it("leaves root-plus-descendant [data-composition-id] selectors as a plain scope prefix", () => { const scoped = scopeCssToComposition( '[data-composition-id="captions"] .title { color: red; }', diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 71225d9e5f..be49f149a0 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -121,12 +121,14 @@ function scopeSelector( const isRootBoxSelector = trimmed.replace(compositionIdPattern, "").trim() === ""; if (isRootBoxSelector) { // A bare root selector styles the composition's own box (flex/grid/ - // position). When flattenInnerRoot preserves the authored root as a - // wrapper below `scope` (see prepareFlattenedInnerRoot), that wrapper - // is the element real children are laid out in, not `scope` itself. - // Target both so the box styling still reaches whichever one holds - // the composition's actual content. - return `${scope}, ${scope} [${INNER_ROOT_ATTR}]`; + // position/padding). When flattenInnerRoot preserves the authored root + // as a wrapper below `scope` (see prepareFlattenedInnerRoot), that + // wrapper is the element real children are laid out in, not `scope` + // itself, so the box styling must land there instead. It must land on + // exactly one of the two: applying it to both compounds any additive + // property (padding, margin, non-zero transform) since the wrapper + // sits nested inside the host and would inherit the effect twice. + return `${scope}:not(:has([${INNER_ROOT_ATTR}])), ${scope} > [${INNER_ROOT_ATTR}]`; } return selectorWithoutRootTiming.replace(compositionIdPattern, scope); } From 86d3693401ba6fb923724b657399b540458b8941 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 2 Jul 2026 13:56:56 -0700 Subject: [PATCH 4/7] fix(core): restore composition id on the wrapper for anonymous hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flattenInnerRoot strips data-composition-id from the flattened wrapper, assuming the host already carries the composition's identity. That's true for hosts authored with their own data-composition-id, but not for a host mounted via data-composition-src with no id of its own (an "anonymous" host) — a pattern the producer already had a dedicated regression test for (missing-host-comp-id) and correctly supported before flattenInnerRoot was wired in, via a different code path that preserved the whole composition element as-is. Once nothing in the render DOM carries the composition's own id, its root- styling CSS (`[data-composition-id="X"] { ... }`) and any script that self-references it (e.g. `document.querySelector('[data-composition-id="X"]')`) both silently stop resolving. Restore the id onto the wrapper specifically when the host has none of its own, matching what the pre-flatten producer path did and what preview visually expects. --- .../compiler/inlineSubCompositions.test.ts | 46 +++++++++++++++++++ .../src/compiler/inlineSubCompositions.ts | 10 ++++ .../src/services/htmlCompiler.test.ts | 11 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts index bf52cffc4e..c20a3c750e 100644 --- a/packages/core/src/compiler/inlineSubCompositions.test.ts +++ b/packages/core/src/compiler/inlineSubCompositions.test.ts @@ -175,6 +175,52 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => { expect(scopedCss).toContain('[data-hf-authored-id="intro"]'); }); + it("with flattenInnerRoot: restores data-composition-id on the wrapper for an anonymous host", () => { + // Regression test: a host mounted via data-composition-src with no + // data-composition-id of its own (an "anonymous" host). The composition + // styles its own root box via the bare composition-id selector and a + // script self-references it too — both need something in the render DOM + // to actually carry that id once flattenInnerRoot strips it from the + // wrapper by default. + const { document } = parseHTML(` + +
+
+
+`); + const host = document.querySelector('[data-composition-src="scoped-text.html"]')!; + + const scopedTextHtml = ``; + + function flattenInnerRoot(innerRoot: Element): Element { + const clone = innerRoot.cloneNode(true) as Element; + clone.removeAttribute("data-composition-id"); + clone.removeAttribute("data-start"); + clone.removeAttribute("data-duration"); + clone.setAttribute("data-hf-inner-root", "true"); + return clone; + } + + const result = inlineSubCompositions(document, [host], { + resolveHtml: () => scopedTextHtml, + parseHtml: (html) => parseHTML(html).document, + flattenInnerRoot, + }); + + const wrapper = host.querySelector("[data-hf-inner-root]"); + expect(wrapper?.getAttribute("data-composition-id")).toBe("scoped-text"); + + const scopedCss = result.styles.join("\n"); + expect(scopedCss).toContain("display: flex"); + }); + it("extracts elements from sub-composition with original rel and crossorigin", () => { const subCompWithLinks = ` diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index bf74cc6a2d..2d7f79da58 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -372,6 +372,16 @@ export function inlineSubCompositions( for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove(); if (flattenInnerRoot) { const prepared = flattenInnerRoot(innerRoot); + if (!compId && inferredCompId) { + // Anonymous host: flattenInnerRoot strips data-composition-id, + // assuming the host already carries the composition's identity. + // When the host has none, nothing in the render DOM matches the + // composition's own root-styling CSS or self-referencing scripts + // (e.g. document.querySelector('[data-composition-id="X"]')). + // Restore it on the wrapper so both keep resolving, same as + // before flattening preserved it via outerHTML. + prepared.setAttribute("data-composition-id", inferredCompId); + } hostEl.innerHTML = prepared.outerHTML || ""; } else { hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || ""; diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index ca2881ebe1..f4d4aa4ea5 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -903,11 +903,12 @@ describe("template-wrapped sub-composition media offsets", () => { const host = document.querySelector("#scene-host"); expect(host?.getAttribute("data-composition-id")).toBeNull(); - // The flattened inner root strips data-composition-id (same as the bundler's - // prepareFlattenedInnerRoot), so an anonymous host's content is only reachable - // via the preserved wrapper, not a [data-composition-id="scene"] selector — - // this matches preview parity, not a producer-specific contract. - expect(host?.querySelector("[data-hf-inner-root] .title")?.textContent).toBe("Scene"); + // The host has no data-composition-id of its own, but the composition's + // own id is restored onto the flattened wrapper, so root-scoped + // selectors and self-referencing scripts still resolve. + const wrapper = host?.querySelector("[data-hf-inner-root]"); + expect(wrapper?.getAttribute("data-composition-id")).toBe("scene"); + expect(wrapper?.querySelector(".title")?.textContent).toBe("Scene"); expect(compiled.html).toContain('var __hfCompId = "scene";'); }); }); From 86ead17af5b284679c6d0d3375e56bce6599469b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 2 Jul 2026 15:22:31 -0700 Subject: [PATCH 5/7] fix(core): resolve sub-composition start time through data-composition-file createRuntimeStartTimeResolver's walk-up-to-host fallback (for an inner root with no data-start of its own) only recognized a host carrying data-composition-src or data-composition-id. Once inlining strips data-composition-src and replaces it with data-composition-file, an anonymous host (no data-composition-id of its own) matched neither check, so the walk-up silently failed and the composition's own timeline got seeked using its start time as the fallback (0) instead of the host's real data-start. This surfaced once the previous commit restored data-composition-id onto the flattened wrapper for anonymous hosts: the composition's self-query started resolving to that wrapper, but resolveStartForElement still couldn't find its actual mount time through the host, so any composition with real entrance/exit animation timing rendered stuck at t=0 (visually: missing or frozen at its hidden initial state) instead of at the correct point in its own timeline. --- .../core/src/runtime/startResolver.test.ts | 33 +++++++++++++++++++ packages/core/src/runtime/startResolver.ts | 13 +++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts index 272fb78bc8..cd15d434fb 100644 --- a/packages/core/src/runtime/startResolver.test.ts +++ b/packages/core/src/runtime/startResolver.test.ts @@ -178,6 +178,39 @@ describe("createRuntimeStartTimeResolver", () => { expect(resolver.resolveStartForElement(video)).toBe(54); }); + it("walks up to the host's data-start when the inner root has none (host has its own data-composition-id)", () => { + const host = document.createElement("div"); + host.setAttribute("data-composition-id", "montage"); + host.setAttribute("data-start", "10"); + document.body.appendChild(host); + + const innerRoot = document.createElement("div"); + innerRoot.setAttribute("data-composition-id", "scene-10"); + host.appendChild(innerRoot); + + const resolver = createRuntimeStartTimeResolver({}); + expect(resolver.resolveStartForElement(innerRoot)).toBe(10); + }); + + it("walks up to the host's data-start via data-composition-file (anonymous host, post-inlining)", () => { + // A host mounted via data-composition-src with no data-composition-id of + // its own. After inlining, data-composition-src is stripped and replaced + // with data-composition-file, and the composition's own id is restored + // onto the wrapper (which has no data-start of its own). + const host = document.createElement("div"); + host.setAttribute("data-composition-file", "compositions/reveal1.html"); + host.setAttribute("data-start", "4.619"); + document.body.appendChild(host); + + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-composition-id", "reveal1"); + wrapper.setAttribute("data-hf-inner-root", "true"); + host.appendChild(wrapper); + + const resolver = createRuntimeStartTimeResolver({}); + expect(resolver.resolveStartForElement(wrapper)).toBe(4.619); + }); + it("keeps nested references in the host composition timeline", () => { const host = document.createElement("div"); host.id = "slide-5"; diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index a343bae693..a797717f25 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -161,15 +161,20 @@ export function createRuntimeStartTimeResolver(params: { // If this element is a loaded composition inner root (has data-composition-id // but no data-start), walk up to the host parent which carries the actual // timing. This happens when the host uses a different data-composition-id - // than the loaded file — e.g. host="montage" but file has "scene-10". - // Check both data-composition-src (runtime) and data-composition-id (bundled, - // where data-composition-src is stripped after inlining). + // than the loaded file — e.g. host="montage" but file has "scene-10", or + // when the host itself has no data-composition-id at all (an "anonymous" + // host) and the composition's own id was restored onto the inlined wrapper. + // Check data-composition-src (runtime, not yet inlined), data-composition-id + // (bundled/compiled host with its own id), and data-composition-file (the + // marker every inlined host gets, compiled or bundled, once + // data-composition-src is stripped — covers the anonymous-host case). if (element.hasAttribute("data-composition-id")) { const parent = element.parentElement; if ( parent && (parent.hasAttribute("data-composition-src") || - parent.hasAttribute("data-composition-id")) + parent.hasAttribute("data-composition-id") || + parent.hasAttribute("data-composition-file")) ) { const parentStart = resolveStartForElementInternal(parent, fallback); startCache.set(element, parentStart); From d9b02aa7dbfd35f1675b74fe3a44ff1fd7c48375 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 2 Jul 2026 23:13:59 -0700 Subject: [PATCH 6/7] test: address PR #1886 review feedback with added coverage and doc fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix a comment in htmlCompiler.ts that misattributed the canonical prepareFlattenedInnerRoot to compositionLoader.ts; the canonical implementation lives in htmlBundler.ts, which compositionLoader.ts mirrors with its own copy for the live-loaded case. - Update sub-comp-id-selector's stale fixture description: it used to document the #ID divergence this PR closes and recommend a workaround that no longer applies. - Add a regression test proving compositionLoader.ts's anonymous-host path does not share the bug this PR fixes: an anonymous host's authoredCompositionId is null, so mountCompositionContent's innerRoot lookup never runs and prepareFlattenedInnerRoot is never reached for it. It falls through to a raw document.importNode() instead, which never stripped data-composition-id in the first place. - Add producer and end-to-end regression coverage for the literal repro from issue #1847 (a class, not just an id, on the authored root, styled via a descendant selector) — this shape wasn't covered by any existing fixture or unit test. --- .../src/runtime/compositionLoader.test.ts | 37 +++++++++++ .../src/services/htmlCompiler.test.ts | 62 +++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 10 +-- .../tests/sub-comp-class-selector/meta.json | 12 ++++ .../src/compositions/scene.html | 36 +++++++++++ .../sub-comp-class-selector/src/index.html | 44 +++++++++++++ .../tests/sub-comp-id-selector/meta.json | 2 +- 7 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 packages/producer/tests/sub-comp-class-selector/meta.json create mode 100644 packages/producer/tests/sub-comp-class-selector/src/compositions/scene.html create mode 100644 packages/producer/tests/sub-comp-class-selector/src/index.html diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts index ba67cc438e..180eb5076d 100644 --- a/packages/core/src/runtime/compositionLoader.test.ts +++ b/packages/core/src/runtime/compositionLoader.test.ts @@ -972,6 +972,43 @@ describe("loadExternalCompositions", () => { expect(byCompAfterSecondMount?.["card-last"]).toBeUndefined(); }); }); + + it("preserves data-composition-id unflattened for a host with no id of its own (anonymous host)", async () => { + // Regression test documenting why this file's own prepareFlattenedInnerRoot + // (line ~527) does NOT need the same anonymous-host id-restoration that + // producer/bundler compilation needed: an anonymous host's authoredCompositionId + // is null, so mountCompositionContent's innerRoot lookup never runs, and it + // falls through to a raw document.importNode() of the whole template content + // instead of prepareFlattenedInnerRoot. The composition's own + // data-composition-id is never stripped in the first place, so its root-styling + // CSS and self-referencing querySelector('[data-composition-id="X"]') calls + // already resolve. See PR review discussion on #1886 for the audit trail. + const host = document.createElement("div"); + host.setAttribute("data-composition-src", "https://example.com/scoped-text.html"); + document.body.appendChild(host); + + const compositionHtml = ` + + `; + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 })); + + await loadExternalCompositions({ ...defaultParams }); + + // Not flattened: no data-hf-inner-root wrapper was created. + expect(host.querySelector("[data-hf-inner-root]")).toBeNull(); + // The composition's own root element, with its own id intact, is a + // direct descendant of the (still anonymous) host. + const mountedRoot = host.querySelector('[data-composition-id="scoped-text"]'); + expect(mountedRoot).not.toBeNull(); + expect(mountedRoot?.querySelector(".label")?.textContent).toBe( + "Scoped Text Should Stay Styled", + ); + }); }); describe("loadInlineTemplateCompositions", () => { diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index f4d4aa4ea5..4a02a2eec6 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -869,6 +869,68 @@ describe("template-wrapped sub-composition media offsets", () => { expect(compiled.html).toContain("__hfNormalizeSelector"); }); + it("resolves a class selector on the authored root wrapper itself (issue #1847 repro)", async () => { + // The original bug report: a sub-composition root authored as + // `
` styled via + // `.scene-wrapper .title { color: red }`. Class-based descendant + // selectors anchored on the authored root's own class only resolve if + // the root survives as a real element in the render DOM, not just via + // id-selector rewriting to [data-hf-authored-id]. + const projectDir = mkdtempSync(join(tmpdir(), "hf-class-wrapper-")); + const compositionsDir = join(projectDir, "compositions"); + mkdirSync(compositionsDir, { recursive: true }); + writeFileSync( + join(projectDir, "index.html"), + ` + + + +
+
+
+ + +`, + ); + writeFileSync( + join(compositionsDir, "scene.html"), + ``, + ); + + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + const { document } = parseHTML(compiled.html); + const host = document.querySelector("#scene-host"); + + const wrapper = host?.querySelector(".scene-wrapper"); + expect(wrapper).not.toBeNull(); + expect(wrapper?.getAttribute("data-hf-authored-id")).toBe("scene-root"); + expect(wrapper?.querySelector(".title")?.textContent).toBe("ISSUE 1847 REPRO"); + // The authored class selector round-trips unmodified: no id rewriting + // is needed for a class selector, only the wrapper element surviving. + expect(compiled.html).toContain(".scene-wrapper .title"); + }); + it("preserves the inferred composition boundary when the host has no composition id", async () => { const projectDir = mkdtempSync(join(tmpdir(), "hf-anonymous-host-")); const compositionsDir = join(projectDir, "compositions"); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index b2eead2299..dfe697a365 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -749,10 +749,12 @@ function inlineSubCompositions( parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document, scriptErrorLabel: "[Compiler] Composition script failed", // Preserve the authored root wrapper as a child of the host, matching - // the preview/runtime shape (compositionLoader's prepareFlattenedInnerRoot). - // Without this, the wrapper element (and its class/id) is discarded and - // any CSS anchored on it — `.wrapper-class .title`, `#wrapper-id` — is - // dead at render time even though it works in preview. + // the preview bundler's shape (htmlBundler.ts's prepareFlattenedInnerRoot, + // which the runtime compositionLoader mirrors with its own copy for the + // live-loaded case). Without this, the wrapper element (and its + // class/id) is discarded and any CSS anchored on it — + // `.wrapper-class .title`, `#wrapper-id` — is dead at render time even + // though it works in preview. flattenInnerRoot: prepareFlattenedInnerRoot as (innerRoot: Element) => Element, onMissingComposition: (srcPath: string, reason?: string) => { // In the render path this is normally unreachable — compileForRender diff --git a/packages/producer/tests/sub-comp-class-selector/meta.json b/packages/producer/tests/sub-comp-class-selector/meta.json new file mode 100644 index 0000000000..61eb7ac8e7 --- /dev/null +++ b/packages/producer/tests/sub-comp-class-selector/meta.json @@ -0,0 +1,12 @@ +{ + "name": "Sub-composition authored-root class selector scoping", + "description": "Regression test for issue #1847 / PR #1886 (the exact reported repro): a sub-composition's authored root carries its own class (not just an id), styled via a descendant selector anchored on that class (`.scene-wrapper .title`). This diverged between preview and render because the producer discarded the authored root element entirely, so no element in the render DOM ever carried the class. The producer now preserves the authored root as a data-hf-inner-root wrapper (matching preview), so the class-based selector resolves identically in both.", + "tags": ["sub-composition", "regression", "selector"], + "minPsnr": 20, + "maxFrameFailures": 10, + "minAudioCorrelation": 0.0, + "maxAudioLagWindows": 120, + "renderConfig": { + "fps": 24 + } +} diff --git a/packages/producer/tests/sub-comp-class-selector/src/compositions/scene.html b/packages/producer/tests/sub-comp-class-selector/src/compositions/scene.html new file mode 100644 index 0000000000..d3cf5a1be7 --- /dev/null +++ b/packages/producer/tests/sub-comp-class-selector/src/compositions/scene.html @@ -0,0 +1,36 @@ + diff --git a/packages/producer/tests/sub-comp-class-selector/src/index.html b/packages/producer/tests/sub-comp-class-selector/src/index.html new file mode 100644 index 0000000000..ef7f5a9ba8 --- /dev/null +++ b/packages/producer/tests/sub-comp-class-selector/src/index.html @@ -0,0 +1,44 @@ + + + + + + + + +
+
+
+ + + diff --git a/packages/producer/tests/sub-comp-id-selector/meta.json b/packages/producer/tests/sub-comp-id-selector/meta.json index 6fd6cd86ea..33f8b9eaa9 100644 --- a/packages/producer/tests/sub-comp-id-selector/meta.json +++ b/packages/producer/tests/sub-comp-id-selector/meta.json @@ -1,6 +1,6 @@ { "name": "Sub-composition #ID selector scoping", - "description": "Documents that sub-compositions using #ID selectors may render differently between preview and render due to the producer stripping the inner root element. Workaround: use [data-composition-id] selectors instead of #ID.", + "description": "Regression test for #1886: a sub-composition's authored root #ID selectors used to render differently between preview and render because the producer stripped the inner root element. The producer now preserves the authored root as a data-hf-inner-root wrapper (matching preview), and #ID selectors are rewritten to a [data-hf-authored-id] attribute on that wrapper, so #ID scoping round-trips correctly in both preview and render.", "tags": ["sub-composition", "regression", "selector"], "minPsnr": 20, "maxFrameFailures": 10, From a5b87f109f4eb9284fa336fa1470096f848c07ab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 3 Jul 2026 00:08:28 -0700 Subject: [PATCH 7/7] test: add golden baseline for sub-comp-class-selector fixture Verified deterministic across 3 runs in Docker on linux/amd64. --- .../output/compiled.html | 380 ++++++++++++++++++ .../sub-comp-class-selector/output/output.mp4 | 3 + 2 files changed, 383 insertions(+) create mode 100644 packages/producer/tests/sub-comp-class-selector/output/compiled.html create mode 100644 packages/producer/tests/sub-comp-class-selector/output/output.mp4 diff --git a/packages/producer/tests/sub-comp-class-selector/output/compiled.html b/packages/producer/tests/sub-comp-class-selector/output/compiled.html new file mode 100644 index 0000000000..00d8970b2c --- /dev/null +++ b/packages/producer/tests/sub-comp-class-selector/output/compiled.html @@ -0,0 +1,380 @@ + + + + + + + + +
+
+
ISSUE 1847 REPRO
+ + + + + +
+
+ + + diff --git a/packages/producer/tests/sub-comp-class-selector/output/output.mp4 b/packages/producer/tests/sub-comp-class-selector/output/output.mp4 new file mode 100644 index 0000000000..28024ef796 --- /dev/null +++ b/packages/producer/tests/sub-comp-class-selector/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22c76cc409f4567792d5f80b9c379c8905ab34cbb90bc11fe398ddf47a4fc4d7 +size 33420