Skip to content
65 changes: 65 additions & 0 deletions packages/core/src/compiler/compositionScoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,71 @@ window.__afterTimeline = window.__timelines.scene;
expect(scoped).not.toMatch(/#intro\b/);
});

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, 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"]: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(
'<div id="host" data-composition-id="captions">' +
'<div id="wrapper" data-hf-inner-root="true"></div>' +
"</div>",
);
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('<div id="host" data-composition-id="captions"></div>');
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; }',
"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(
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
@@ -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, "\\$&");
Expand Down Expand Up @@ -117,6 +118,18 @@ 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/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);
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-src="scoped-text.html" data-start="0" data-duration="3"></div>
</div>
</body></html>`);
const host = document.querySelector('[data-composition-src="scoped-text.html"]')!;

const scopedTextHtml = `<template id="scoped-text-template">
<div data-composition-id="scoped-text" data-width="1080" data-height="1920" data-duration="3">
<div class="label">Scoped Text Should Stay Styled</div>
<style>
[data-composition-id="scoped-text"] { display: flex; background: rgb(12, 12, 12); }
</style>
</div>
</template>`;

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 <link> elements from sub-composition <head> with original rel and crossorigin", () => {
const subCompWithLinks = `<!doctype html>
<html><head>
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/runtime/compositionLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<template id="scoped-text-template">
<div data-composition-id="scoped-text" data-width="1080" data-height="1920">
<div class="label">Scoped Text Should Stay Styled</div>
</div>
</template>
`;

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", () => {
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/runtime/startResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/runtime/startResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
69 changes: 68 additions & 1 deletion packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<div id="scene-root" class="scene-wrapper">` 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"),
`<!DOCTYPE html>
<html>
<head></head>
<body>
<div id="root" data-composition-id="root" data-start="0" data-width="1920" data-height="1080" data-duration="3">
<div
id="scene-host"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="3"
></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["root"] = { duration: () => 3 };
</script>
</body>
</html>`,
);
writeFileSync(
join(compositionsDir, "scene.html"),
`<template id="scene-template">
<div id="scene-root" class="scene-wrapper" data-composition-id="scene" data-width="1920" data-height="1080" data-duration="3">
<div class="title">ISSUE 1847 REPRO</div>
<style>
.scene-wrapper { background: #111; }
.scene-wrapper .title { color: red; }
</style>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = { duration: () => 3 };
</script>
</div>
</template>`,
);

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");
Expand Down Expand Up @@ -903,7 +965,12 @@ 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 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";');
});
});
Expand Down
Loading
Loading