refactor(core): settle the four remaining preview-vs-render divergences (#3097)

## Why

#3094 fixed one way the mount and render paths disagreed, and added the gate that catches disagreement. It deliberately left the rest.

Four divergences are still live. Each one means a composition assembles differently depending on whether it is being previewed or rendered — the same class of defect that shipped three catalog components unstyled, just with smaller blast radii.

## How

Both paths now derive root discovery, scope identity, asset sources and order, hoisted links, variable carriers and nested-host enumeration from the shared module #3094 introduced. Each keeps its own I/O, which is where they genuinely differ. The compiler's local depth cap and root lookup and the runtime's three pre-filtered head parameters are gone; the runtime hands over the head node and lets the module decide what comes out of it.

Four behaviour changes, each stated by what actually differs rather than by the edit:

**Inline `<head>` scripts.** The compiler looped head scripts with a `src` branch and no `else`, so an inline one was silently discarded on render while the runtime ran it. That is losing code, not holding a convention — the runtime's answer wins. Head and content scripts now share one loop, head first, order preserved. A non-templated sub-composition with an inline head script went from **0 collected scripts to 1**, wrapped, body intact.

**`<link>` hoisting.** Conditional on render, unconditional on mount, so a templated sub-composition's webfont link was dropped in video and kept in preview. Hoisting is the superset and matches what the author declared. A templated composition with a stylesheet link went from **no external links to that link**. The parity fixture that previously recorded this shape as a known exclusion now gates it.

**Anonymous hosts.** With a host naming no id, the compiler fell back to the first declared composition and scoped to it; the mount left the content unflattened and injected its stylesheet into the host `<head>` **unscoped**, so a composition's CSS leaked into whatever mounted it. The compiler's answer wins. The injected rule went from a bare `.label { … }` to `[data-composition-id="scoped-text"] .label { … }`.

**Scope ids.** The compiler splits the CSS scope id from the script composition id; they differ only when a host names an id the content does not declare, and there the scripts follow the declared id so their self-referencing queries resolve. The runtime used one for both. The split wins: a host naming `captions-comp` over content declaring `captions` now emits scripts bound to `captions` while its CSS still scopes to `captions-comp`.

## Test plan

- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)

Core 1694 passing, producer 574 passing, the parity contract now gates the two divergences it can observe (the other two carry no contract field, so they are gated by unit tests naming the exact before/after). Lint 0, `typecheck:runtime` and the runtime preview guards clean, package cycles unchanged.

Characterization-first: both suites were run and recorded green before any decision moved, so a behavioural drift would surface as a red test rather than a silent difference.

**One assertion changed, deliberately.** A runtime test asserted that an anonymous host's composition is *not* flattened, and documented that as intentional. That premise is now false. What the test actually cared about — the root and its content present under the host — still holds and is still asserted; the "not flattened" claim flipped, and the test now also asserts the scoping that was missing.

## Not covered

The variable-carrier divergence and its `TODO(template-var-carriers)` are untouched by design, as is recursion on the mount path — a sub-composition containing its own `data-composition-src` is still silently dropped in live preview. Both are behaviour changes with their own units, and both are now one-line-ish changes because the shared module already reports what they need.

`runtimeScopeCompositionId` no longer falls back to the authored scope id. This is a functional change beyond the four above, surfaced in review: for an anonymous host with authored variable defaults, the runtime previously stashed them under the declared id, and now does not. It removes a runtime-vs-compiler divergence in the correct direction — the runtime was doing work the compiler never did, and the compiler is authoritative for a shipped composition — but a caller relying on runtime-only variable exposure loses it.

The three copies each of the flattened-root helper and the id assignment are left alone: they look mergeable and are not cheaply, and they touch the instancing contract the pixel harness guards.

## Worth knowing

The parity test's compiler arms import core's **built dist** while the mount arm imports source, so core must be rebuilt before that lane means anything after a compiler change. Skipping it produces a phantom divergence that looks exactly like a real one.
This commit is contained in:
Miguel Ángel
2026-08-07 15:32:20 -07:00
committed by GitHub
parent b57dc13cb6
commit 57ec008cb2
7 changed files with 299 additions and 181 deletions
@@ -206,7 +206,7 @@ describe("enumerateNestedCompositionHosts", () => {
return host; return host;
}; };
it("enumerates nested hosts with their ancestry", () => { it("enumerates every nested host in document order", () => {
const host = assembled( const host = assembled(
`<div data-composition-src="child-a.html"></div><div data-composition-src="child-b.html"></div>`, `<div data-composition-src="child-a.html"></div><div data-composition-src="child-b.html"></div>`,
); );
@@ -214,7 +214,6 @@ describe("enumerateNestedCompositionHosts", () => {
const { hosts, skipped } = enumerateNestedCompositionHosts(host, ["outer.html"]); const { hosts, skipped } = enumerateNestedCompositionHosts(host, ["outer.html"]);
expect(hosts.map((entry) => entry.src)).toEqual(["child-a.html", "child-b.html"]); expect(hosts.map((entry) => entry.src)).toEqual(["child-a.html", "child-b.html"]);
expect(hosts[0]?.ancestry).toEqual(["outer.html", "child-a.html"]);
expect(hosts[0]?.host.getAttribute("data-composition-src")).toBe("child-a.html"); expect(hosts[0]?.host.getAttribute("data-composition-src")).toBe("child-a.html");
expect(skipped).toEqual([]); expect(skipped).toEqual([]);
}); });
@@ -205,12 +205,6 @@ export type NestedHostSkipReason = "circular composition reference" | "nesting d
export interface NestedCompositionHost<TElement> { export interface NestedCompositionHost<TElement> {
host: TElement; host: TElement;
src: string; src: string;
/**
* The chain of `data-composition-src` values from the outermost composition
* down to AND INCLUDING this host's own `src`. Pass it straight back into
* `enumerateNestedCompositionHosts` when this host is itself assembled.
*/
ancestry: string[];
} }
export interface NestedCompositionHosts<TElement> { export interface NestedCompositionHosts<TElement> {
@@ -243,7 +237,7 @@ export function enumerateNestedCompositionHosts<TElement extends AssemblyAttribu
skipped.push({ src, reason: "nesting depth exceeded" }); skipped.push({ src, reason: "nesting depth exceeded" });
continue; continue;
} }
hosts.push({ host: nestedHost, src, ancestry: [...ancestry, src] }); hosts.push({ host: nestedHost, src });
} }
return { hosts, skipped }; return { hosts, skipped };
@@ -286,6 +286,61 @@ describe("inlineSubCompositions #ID selector scoping divergence", () => {
}); });
}); });
it("collects an inline <head> script instead of discarding it", () => {
// The <head> loop had a `src` branch and no else, so an inline <head>
// script was silently dropped on render while the mount path executed it.
const subCompWithHeadScript = `<!doctype html>
<html><head>
<script>window.__headScriptRan = true;</script>
</head><body>
<div data-composition-id="intro" data-width="1920" data-height="1080"><span>Hi</span></div>
</body></html>`;
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => subCompWithHeadScript,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.scripts.join("\n")).toContain("window.__headScriptRan = true;");
expect(result.scriptItems).toContainEqual({
kind: "inline",
content: expect.stringContaining("window.__headScriptRan = true;"),
});
});
it("hoists a <link> from a TEMPLATED sub-composition's head", () => {
// Hoisting used to be gated on the composition being non-templated, so a
// templated composition's webfont link was kept in preview (the mount path
// hoists unconditionally) and dropped from the render.
const templatedSubCompWithLink = `<!doctype html>
<html><head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Montserrat">
</head><body>
<template id="intro-template">
<div data-composition-id="intro" data-width="1920" data-height="1080"><span>Hi</span></div>
</template>
</body></html>`;
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => templatedSubCompWithLink,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toEqual([
{
href: "https://fonts.googleapis.com/css2?family=Montserrat",
rel: "stylesheet",
crossorigin: undefined,
},
]);
});
it("deduplicates link hrefs across multiple sub-compositions", () => { it("deduplicates link hrefs across multiple sub-compositions", () => {
const subComp = `<!doctype html> const subComp = `<!doctype html>
<html><head> <html><head>
@@ -15,13 +15,13 @@ import {
rewriteInlineStyleAssetUrls, rewriteInlineStyleAssetUrls,
type AssetExists, type AssetExists,
} from "./rewriteSubCompPaths"; } from "./rewriteSubCompPaths";
import { queryByAttr } from "../utils/cssSelector";
import { import {
scopeCssToComposition, scopeCssToComposition,
wrapInlineScriptWithErrorBoundary, wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript, wrapScopedCompositionScript,
} from "./compositionScoping"; } from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity"; import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
import { enumerateNestedCompositionHosts, planCompositionAssembly } from "./compositionAssembly";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public interface // Public interface
@@ -141,8 +141,6 @@ function defaultBuildScopeSelector(compId: string): string {
return `[data-composition-id="${escaped}"]`; return `[data-composition-id="${escaped}"]`;
} }
const MAX_SUB_COMPOSITION_DEPTH = 20;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Core implementation // Core implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -244,21 +242,21 @@ export function inlineSubCompositions(
continue; continue;
} }
// Keep structural flattening tied to an exact mount-id match. A template // Which node is the composition root, which id its CSS scopes to, which
// may intentionally use a different local id (for example, a // id its scripts scope to, where its assets come from and in what order —
// `captions-comp` host mounting a `captions` template); flattening that // every one of those is decided by the shared assembly module, so the mount
// fallback root changes the compiled DOM and can invalidate selectors and // path in runtime/compositionLoader.ts decides them the same way.
// regression goldens. Discover it separately so script timeline const plan = planCompositionAssembly<Element>({
// registration can still map the authored id onto the runtime mount id. contentNode: contentDoc,
const innerRoot = compId head: compDoc.head,
? queryByAttr(contentDoc, "data-composition-id", compId) documentElement: compDoc.documentElement,
: contentDoc.querySelector("[data-composition-id]"); hasTemplate: Boolean(contentRoot),
const authoredCompositionRoot = innerRoot ?? contentDoc.querySelector("[data-composition-id]"); compositionId: compId,
const inferredCompId = });
authoredCompositionRoot?.getAttribute("data-composition-id")?.trim() || ""; const innerRoot = plan.innerRoot;
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null; const authoredRootId = plan.authoredRootId;
const scopeCompId = compId || inferredCompId; const scopeCompId = plan.authoredCompositionId || "";
const scriptCompositionId = inferredCompId || scopeCompId; const scriptCompositionId = plan.scriptCompositionId || "";
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : ""; const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
// Variable merging (bundler feature). Read declared defaults from the // Variable merging (bundler feature). Read declared defaults from the
@@ -266,11 +264,11 @@ export function inlineSubCompositions(
// (template/fragment sub-comps store their schema on the root div, not a // (template/fragment sub-comps store their schema on the root div, not a
// synthetic <html>), then let per-instance host values override. // synthetic <html>), then let per-instance host values override.
if (readVariableDefaults && parseHostVariables && runtimeCompId) { if (readVariableDefaults && parseHostVariables && runtimeCompId) {
const mergedVariables = { const mergedVariables: Record<string, unknown> = {};
...readVariableDefaults(compDoc.documentElement), for (const carrier of plan.variableDefaultCarriers) {
...(innerRoot ? readVariableDefaults(innerRoot) : {}), Object.assign(mergedVariables, readVariableDefaults(carrier));
...parseHostVariables(hostEl), }
}; Object.assign(mergedVariables, parseHostVariables(hostEl));
if (Object.keys(mergedVariables).length > 0) { if (Object.keys(mergedVariables).length > 0) {
variablesByComp[runtimeCompId] = mergedVariables; variablesByComp[runtimeCompId] = mergedVariables;
} }
@@ -295,47 +293,35 @@ export function inlineSubCompositions(
: css; : css;
}; };
// When a sub-composition is a full HTML document (no <template>), styles // <link> hoisting is unconditional. A templated sub-composition's webfont
// and scripts in <head> are not part of contentDoc (which only has body // link is as load-bearing as a non-templated one's, and the mount path has
// content). Extract them so backgrounds, positioning, fonts, and library // always hoisted both; gating this on `!contentRoot` dropped a templated
// scripts (e.g. GSAP CDN) are not silently dropped. // composition's font from the render while preview kept it.
if (!contentRoot && compDoc.head) { for (const link of plan.linkSources) {
for (const s of [...compDoc.head.querySelectorAll("style")]) { const href = resolveSubAssetPath(link.getAttribute("href"));
styles.push(scopeSubStyle(s.textContent || "")); if (href && !seenLinkHrefs.has(href)) {
} seenLinkHrefs.add(href);
for (const s of [...compDoc.head.querySelectorAll("script")]) { const rel = (link.getAttribute("rel") || "").trim();
const externalSrc = resolveSubAssetPath(s.getAttribute("src")); const crossorigin = link.hasAttribute("crossorigin")
if (externalSrc) { ? link.getAttribute("crossorigin") || ""
if (!externalScriptSrcs.includes(externalSrc)) { : undefined;
externalScriptSrcs.push(externalSrc); externalLinks.push({ href, rel, crossorigin });
}
scriptItems.push({ kind: "external", src: externalSrc });
}
}
for (const link of [
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
]) {
const href = resolveSubAssetPath(link.getAttribute("href"));
if (href && !seenLinkHrefs.has(href)) {
seenLinkHrefs.add(href);
const rel = (link.getAttribute("rel") || "").trim();
const crossorigin = link.hasAttribute("crossorigin")
? link.getAttribute("crossorigin") || ""
: undefined;
externalLinks.push({ href, rel, crossorigin });
}
} }
} }
// Extract styles from content // Head-sourced assets come first: a non-templated sub-composition's <head>
for (const s of [...contentDoc.querySelectorAll("style")]) { // carries its backgrounds, positioning and fonts, and a <head> library tag
styles.push(scopeSubStyle(s.textContent || "")); // (GSAP from a CDN) has to run before the content scripts calling into it.
s.remove(); for (const styleEl of plan.styleSources) {
styles.push(scopeSubStyle(styleEl.textContent || ""));
styleEl.remove();
} }
// Extract scripts from content // Head- and content-sourced scripts take the same branch. The head loop
for (const s of [...contentDoc.querySelectorAll("script")]) { // used to handle only `src`, so an inline <head> script was silently
const externalSrc = resolveSubAssetPath(s.getAttribute("src")); // discarded on render while the mount path executed it.
for (const scriptEl of plan.scriptSources) {
const externalSrc = resolveSubAssetPath(scriptEl.getAttribute("src"));
if (externalSrc) { if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) { if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc); externalScriptSrcs.push(externalSrc);
@@ -344,18 +330,18 @@ export function inlineSubCompositions(
} else { } else {
const wrappedScript = scriptCompositionId const wrappedScript = scriptCompositionId
? wrapScopedCompositionScript( ? wrapScopedCompositionScript(
s.textContent || "", scriptEl.textContent || "",
scriptCompositionId, scriptCompositionId,
scriptErrorLabel, scriptErrorLabel,
runtimeScope || undefined, runtimeScope || undefined,
runtimeCompId || scopeCompId || scriptCompositionId, runtimeCompId || scopeCompId || scriptCompositionId,
authoredRootId, authoredRootId,
) )
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel); : wrapInlineScriptWithErrorBoundary(scriptEl.textContent || "", scriptErrorLabel);
scripts.push(wrappedScript); scripts.push(wrappedScript);
scriptItems.push({ kind: "inline", content: wrappedScript }); scriptItems.push({ kind: "inline", content: wrappedScript });
} }
s.remove(); scriptEl.remove();
} }
// Rewrite relative asset paths before inlining so ../foo.svg from // Rewrite relative asset paths before inlining so ../foo.svg from
@@ -408,7 +394,7 @@ export function inlineSubCompositions(
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove(); for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
if (flattenInnerRoot) { if (flattenInnerRoot) {
const prepared = flattenInnerRoot(innerRoot); const prepared = flattenInnerRoot(innerRoot);
if (!compId && inferredCompId) { if (!compId && scopeCompId) {
// Anonymous host: flattenInnerRoot strips data-composition-id, // Anonymous host: flattenInnerRoot strips data-composition-id,
// assuming the host already carries the composition's identity. // assuming the host already carries the composition's identity.
// When the host has none, nothing in the render DOM matches the // When the host has none, nothing in the render DOM matches the
@@ -416,7 +402,7 @@ export function inlineSubCompositions(
// (e.g. document.querySelector('[data-composition-id="X"]')). // (e.g. document.querySelector('[data-composition-id="X"]')).
// Restore it on the wrapper so both keep resolving, same as // Restore it on the wrapper so both keep resolving, same as
// before flattening preserved it via outerHTML. // before flattening preserved it via outerHTML.
prepared.setAttribute("data-composition-id", inferredCompId); prepared.setAttribute("data-composition-id", scopeCompId);
} }
hostEl.innerHTML = prepared.outerHTML || ""; hostEl.innerHTML = prepared.outerHTML || "";
} else { } else {
@@ -441,18 +427,12 @@ export function inlineSubCompositions(
hostEl.removeAttribute("data-composition-src"); hostEl.removeAttribute("data-composition-src");
const nestedAncestry = [...ancestry, src]; const nestedAncestry = [...ancestry, src];
for (const nestedHost of [...hostEl.querySelectorAll("[data-composition-src]")]) { const nested = enumerateNestedCompositionHosts(hostEl, nestedAncestry);
const nestedSrc = nestedHost.getAttribute("data-composition-src"); for (const skipped of nested.skipped) {
if (!nestedSrc) continue; onMissingComposition?.(skipped.src, skipped.reason);
if (nestedAncestry.includes(nestedSrc)) { }
onMissingComposition?.(nestedSrc, "circular composition reference"); for (const nestedHost of nested.hosts) {
continue; queue.push({ element: nestedHost.host, ancestry: nestedAncestry });
}
if (nestedAncestry.length >= MAX_SUB_COMPOSITION_DEPTH) {
onMissingComposition?.(nestedSrc, "nesting depth exceeded");
continue;
}
queue.push({ element: nestedHost, ancestry: nestedAncestry });
} }
} }
@@ -1219,22 +1219,19 @@ describe("loadExternalCompositions", () => {
}); });
}); });
it("preserves data-composition-id unflattened for a host with no id of its own (anonymous host)", async () => { it("scopes an anonymous host's composition to the id its own content declares", async () => {
// Regression test documenting why this file's own prepareFlattenedInnerRoot // A host naming no composition id used to mount the fetched content WHOLE:
// (line ~527) does NOT need the same anonymous-host id-restoration that // unflattened, and with its CSS injected unscoped, so the composition's
// producer/bundler compilation needed: an anonymous host's authoredCompositionId // rules landed on the host document at large. The compiler has always
// is null, so mountCompositionContent's innerRoot lookup never runs, and it // fallen back to the first root declared in the content, scoped to it, and
// falls through to a raw document.importNode() of the whole template content // restored the id that flattening strips. The mount path does the same now.
// 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"); const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/scoped-text.html"); host.setAttribute("data-composition-src", "https://example.com/scoped-text.html");
document.body.appendChild(host); document.body.appendChild(host);
const compositionHtml = ` const compositionHtml = `
<template id="scoped-text-template"> <template id="scoped-text-template">
<style>.label { color: rgb(1, 2, 3); }</style>
<div data-composition-id="scoped-text" data-width="1080" data-height="1920"> <div data-composition-id="scoped-text" data-width="1080" data-height="1920">
<div class="label">Scoped Text Should Stay Styled</div> <div class="label">Scoped Text Should Stay Styled</div>
</div> </div>
@@ -1243,17 +1240,76 @@ describe("loadExternalCompositions", () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 })); vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams }); const injectedStyles: HTMLStyleElement[] = [];
await loadExternalCompositions({ ...defaultParams, injectedStyles });
// Not flattened: no data-hf-inner-root wrapper was created. // Flattened like every other mount, with the declared id restored so the
expect(host.querySelector("[data-hf-inner-root]")).toBeNull(); // composition's scoped CSS and self-referencing queries still resolve.
// 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"]'); const mountedRoot = host.querySelector('[data-composition-id="scoped-text"]');
expect(mountedRoot).not.toBeNull(); expect(mountedRoot?.getAttribute("data-hf-inner-root")).toBe("true");
expect(mountedRoot?.querySelector(".label")?.textContent).toBe( expect(mountedRoot?.querySelector(".label")?.textContent).toBe(
"Scoped Text Should Stay Styled", "Scoped Text Should Stay Styled",
); );
// The stylesheet is scoped to the composition rather than leaking whole.
const injectedCss = injectedStyles.map((style) => style.textContent).join("\n");
expect(injectedCss).toContain('[data-composition-id="scoped-text"]');
expect(injectedCss).not.toMatch(/^\s*\.label\s*\{/);
});
// --- D1/D4 regressions: what the compiler and the mount path now agree on ---
it("executes an inline <head> script of a non-templated composition", async () => {
// The compiler used to drop this one on the floor (its <head> loop had a
// `src` branch and no else); the mount path always executed it.
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/head-script.html");
host.setAttribute("data-composition-id", "head-script");
document.body.appendChild(host);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
`<html><head><script>window.__headScriptRan = true;</script></head><body>
<div data-composition-id="head-script"><p>Body</p></div>
</body></html>`,
{ status: 200 },
),
);
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({ ...defaultParams, injectedScripts });
expect(injectedScripts.map((script) => script.textContent).join("\n")).toContain(
"window.__headScriptRan = true;",
);
});
it("scopes scripts to the id the content declares when the host names another", async () => {
// Two scope ids, not one: CSS stays on the id the host asked for, scripts
// follow the id the content actually declares, so a script's own
// querySelector('[data-composition-id="..."]') resolves. Collapsing them
// pointed every self-query at an id that is nowhere in the content.
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/captions.html");
host.setAttribute("data-composition-id", "captions-comp");
document.body.appendChild(host);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
`<template id="captions-comp-template">
<div data-composition-id="captions"><p>Caption</p></div>
<script>void 0;</script>
</template>`,
{ status: 200 },
),
);
const injectedScripts: HTMLScriptElement[] = [];
const injectedStyles: HTMLStyleElement[] = [];
await loadExternalCompositions({ ...defaultParams, injectedScripts, injectedStyles });
const scriptSource = injectedScripts.map((script) => script.textContent).join("\n");
expect(scriptSource).toContain('var __hfCompId = "captions";');
expect(scriptSource).not.toContain('var __hfCompId = "captions-comp";');
}); });
}); });
+67 -74
View File
@@ -1,3 +1,4 @@
import { planCompositionAssembly } from "../compiler/compositionAssembly";
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping"; import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
import { markFlattenedInnerRoot } from "./flattenedRoot"; import { markFlattenedInnerRoot } from "./flattenedRoot";
import { import {
@@ -383,12 +384,12 @@ async function mountCompositionContent(params: {
injectedScripts: HTMLScriptElement[]; injectedScripts: HTMLScriptElement[];
injectedLinks: HTMLLinkElement[]; injectedLinks: HTMLLinkElement[];
parseDimensionPx: (value: string | null) => string | null; parseDimensionPx: (value: string | null) => string | null;
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */ /**
headStyles?: HTMLStyleElement[]; * The parsed document's `<head>`, when the composition was loaded as a full
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */ * HTML document. What comes out of it is the shared assembly module's call:
headScripts?: HTMLScriptElement[]; * styles and scripts only for a non-templated composition, links always.
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */ */
headLinks?: HTMLLinkElement[]; head?: ParentNode | null;
/** /**
* Defaults extracted from the sub-composition's own * Defaults extracted from the sub-composition's own
* `<html data-composition-variables="...">` attribute. Layered under the * `<html data-composition-variables="...">` attribute. Layered under the
@@ -403,43 +404,51 @@ async function mountCompositionContent(params: {
details: Record<string, string | number | boolean | null | string[]>; details: Record<string, string | number | boolean | null | string[]>;
}) => void; }) => void;
}): Promise<void> { }): Promise<void> {
let innerRoot: HTMLElement | null = null; // Which node is the composition root, which id its CSS scopes to, which id
if (params.authoredCompositionId) { // its scripts scope to, where its assets come from and in what order: the
const candidateRoots = Array.from( // shared assembly module answers all of it, so this path and the compiler's
params.sourceNode.querySelectorAll<HTMLElement>("[data-composition-id]"), // inlineSubCompositions agree by construction. Notably, an ANONYMOUS host
); // (one naming no composition id) now falls back to the first root declared
innerRoot = // in the content and mounts scoped to it — mounting the content whole left
candidateRoots.find( // its CSS unscoped and leaking into the host document.
(candidate) => const plan = planCompositionAssembly<Element>({
candidate instanceof HTMLElement && contentNode: params.sourceNode,
candidate.getAttribute("data-composition-id") === params.authoredCompositionId, head: params.head,
) ?? null; hasTemplate: params.hasTemplate,
} compositionId: params.authoredCompositionId,
});
// The mount sizes and flattens the root, which needs an HTMLElement; a root
// that is not one mounts as plain content, exactly as before.
const innerRoot = plan.innerRoot instanceof HTMLElement ? plan.innerRoot : null;
const contentNode = innerRoot ?? params.sourceNode; const contentNode = innerRoot ?? params.sourceNode;
const authoredScopeCompositionId = const authoredScopeCompositionId = plan.authoredCompositionId;
innerRoot?.getAttribute("data-composition-id")?.trim() || params.authoredCompositionId || null; // Scripts follow the id the CONTENT declares, CSS the id the HOST asked for.
const runtimeScopeCompositionId = // They differ only when a host names an id no root in the content declares,
params.runtimeCompositionId || authoredScopeCompositionId || null; // where collapsing them breaks a script's own
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null; // `querySelector('[data-composition-id="..."]')`.
const scriptScopeCompositionId = plan.scriptCompositionId;
// No fallback to the authored id: an anonymous host has no runtime id, and
// the compiler emits no runtime scope selector and no variable table for one.
const runtimeScopeCompositionId = params.runtimeCompositionId || null;
const authoredRootId = plan.authoredRootId;
const runtimeScopeSelector = runtimeScopeCompositionId const runtimeScopeSelector = runtimeScopeCompositionId
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]` ? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
: undefined; : undefined;
if (params.headLinks) { for (const link of plan.linkSources) {
for (const link of params.headLinks) { const rawHref = (link.getAttribute("href") || "").trim();
const rawHref = (link.getAttribute("href") || "").trim(); if (!rawHref) continue;
if (!rawHref) continue; const href = params.compositionUrl ? new URL(rawHref, params.compositionUrl).href : rawHref;
const href = params.compositionUrl ? new URL(rawHref, params.compositionUrl).href : rawHref; if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue;
if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue; if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue; const clonedLink = link.cloneNode(true);
const clonedLink = link.cloneNode(true) as HTMLLinkElement; if (!(clonedLink instanceof HTMLLinkElement)) continue;
clonedLink.href = href; clonedLink.href = href;
document.head.appendChild(clonedLink); document.head.appendChild(clonedLink);
params.injectedLinks.push(clonedLink); params.injectedLinks.push(clonedLink);
}
} }
const injectScopedStyles = (styleEls: Iterable<HTMLStyleElement>): void => { const injectScopedStyles = (styleEls: Iterable<Element>): void => {
for (const style of styleEls) { for (const style of styleEls) {
const clonedStyle = style.cloneNode(true); const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue; if (!(clonedStyle instanceof HTMLStyleElement)) continue;
@@ -460,18 +469,13 @@ async function mountCompositionContent(params: {
params.injectedStyles.push(clonedStyle); params.injectedStyles.push(clonedStyle);
} }
}; };
// Inject <head> styles from non-template sub-compositions first (they define // Already in injection order: <head> styles from a non-template composition
// element styles like backgrounds and positioning that the composition needs), // first (they define backgrounds and positioning the composition needs), then
// then the content styles. // the content's — including the ones authored as SIBLINGS of the composition
if (params.headStyles) injectScopedStyles(params.headStyles); // root, the shape whose omission dropped a mounted composition's stylesheet.
// Collect from `sourceNode`, not the composition root: the canonical authored injectScopedStyles(plan.styleSources);
// shape puts <style>/<script> as SIBLINGS of the root inside <template>, so
// scanning only the root dropped a composition's entire stylesheet (and with
// it `#root { container-type: size }`, leaving every cq* unit unanchored).
// `sourceNode` is a superset of the root, so nothing is collected twice.
injectScopedStyles(Array.from(params.sourceNode.querySelectorAll<HTMLStyleElement>("style")));
const toPendingScript = (script: HTMLScriptElement): PendingScript | null => { const toPendingScript = (script: Element): PendingScript | null => {
const type = script.getAttribute("type")?.trim() ?? ""; const type = script.getAttribute("type")?.trim() ?? "";
const src = script.getAttribute("src")?.trim() ?? ""; const src = script.getAttribute("src")?.trim() ?? "";
if (src) { if (src) {
@@ -484,15 +488,12 @@ async function mountCompositionContent(params: {
} }
const content = script.textContent?.trim() ?? ""; const content = script.textContent?.trim() ?? "";
if (!content) return null; if (!content) return null;
return { kind: "inline", content, type, scopeCompositionId: authoredScopeCompositionId }; return { kind: "inline", content, type, scopeCompositionId: scriptScopeCompositionId };
}; };
// <head> scripts first (e.g. a GSAP CDN tag in a non-template sub-comp): they // Already in execution order: <head> scripts first (a GSAP CDN tag in a
// must execute before the content scripts that call into them. // non-template sub-comp) so they run before the content scripts calling in.
const scriptPayloads = [ const scriptPayloads = plan.scriptSources
...(params.headScripts ?? []),
...Array.from(params.sourceNode.querySelectorAll<HTMLScriptElement>("script")),
]
.map(toPendingScript) .map(toPendingScript)
.filter((payload): payload is PendingScript => payload !== null); .filter((payload): payload is PendingScript => payload !== null);
@@ -509,6 +510,13 @@ async function mountCompositionContent(params: {
params.host.setAttribute("data-timeline-locked", ""); params.host.setAttribute("data-timeline-locked", "");
} }
const flattenedRoot = prepareFlattenedInnerRoot(innerRoot); const flattenedRoot = prepareFlattenedInnerRoot(innerRoot);
if (!params.authoredCompositionId && authoredScopeCompositionId) {
// Flattening strips data-composition-id on the assumption the host
// carries the composition's identity. An anonymous host does not, so
// restore it or nothing in the mounted DOM matches the composition's own
// scoped CSS. Mirrors the identical restore in inlineSubCompositions.
flattenedRoot.setAttribute("data-composition-id", authoredScopeCompositionId);
}
stripExtractedCompositionAssets(flattenedRoot); stripExtractedCompositionAssets(flattenedRoot);
params.host.appendChild(flattenedRoot); params.host.appendChild(flattenedRoot);
} else if (params.hasTemplate) { } else if (params.hasTemplate) {
@@ -689,23 +697,6 @@ export async function loadExternalCompositions(
) )
: null) ?? doc.querySelector<HTMLTemplateElement>("template"); : null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body; const sourceNode = template ? template.content : doc.body;
// When loading a non-template sub-composition (full HTML document),
// extract <style> and <script> elements from the parsed document's
// <head>. These contain critical CSS (backgrounds, positioning, fonts)
// and library scripts (e.g. GSAP CDN) that would otherwise be lost
// because mountCompositionContent only looks inside the composition
// root element.
const headStyles = !template
? Array.from(doc.head.querySelectorAll<HTMLStyleElement>("style"))
: undefined;
const headScripts = !template
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
: undefined;
const headLinks = Array.from(
doc.head.querySelectorAll<HTMLLinkElement>(
'link[rel="stylesheet"], link[rel="preconnect"]',
),
);
await mountCompositionContent({ await mountCompositionContent({
host, host,
authoredCompositionId, authoredCompositionId,
@@ -719,9 +710,11 @@ export async function loadExternalCompositions(
injectedScripts: params.injectedScripts, injectedScripts: params.injectedScripts,
injectedLinks: params.injectedLinks, injectedLinks: params.injectedLinks,
parseDimensionPx: params.parseDimensionPx, parseDimensionPx: params.parseDimensionPx,
headStyles, // A non-templated composition's <head> carries critical CSS
headScripts, // (backgrounds, positioning, fonts) and library scripts; every
headLinks, // composition's <head> can carry a webfont <link>. The shared
// assembly module decides which of those apply.
head: doc.head,
// TODO(template-var-carriers): reads `<html>` only. A template/fragment // TODO(template-var-carriers): reads `<html>` only. A template/fragment
// sub-comp that declares on its `[data-composition-id]` root div (the // sub-comp that declares on its `[data-composition-id]` root div (the
// dual-carrier contract from #2081) loses its defaults on this lazy // dual-carrier contract from #2081) loses its defaults on this lazy
@@ -151,6 +151,15 @@ describe("preview/render semantic compilation parity", () => {
const FONT_FACE = `@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }`; const FONT_FACE = `@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }`;
const anonymousCardHost = (body: string) => ({
"index.html":
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="6">
<section id="card-host" data-composition-src="compositions/card.html"
data-start="1" data-duration="3"></section>
</main>`),
"compositions/card.html": body,
});
const cardHost = (body: string) => ({ const cardHost = (body: string) => ({
"index.html": "index.html":
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="6"> shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="6">
@@ -186,6 +195,37 @@ const MOUNT_PARITY_FIXTURES: { name: string; files: Record<string, string> }[] =
</article> </article>
</template>`), </template>`),
}, },
{
name: "a TEMPLATED composition hoisting a head stylesheet link",
// The compiler used to hoist a <link> only for a non-templated
// composition, so a templated one's webfont survived preview (the mount
// path always hoisted) and vanished from the render.
files: cardHost(`<!doctype html><html><head>
<link rel="preconnect" href="https://fonts.example.com" />
</head><body>
<template id="card-template">
<style>${FONT_FACE}
.parity-card { --parity-contract: 6; font-family: ParityBody, sans-serif; }</style>
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
<h2 class="parity-card">Card</h2>
</article>
</template>
</body></html>`),
},
{
name: "an anonymous host scoping to the id its content declares",
// A host naming no composition id. The mount path used to drop the
// content in whole and unscoped, so this composition's CSS landed on the
// host document at large; the compiler has always fallen back to the
// first declared root and scoped to it.
files: anonymousCardHost(`<template id="card-template">
<style>${FONT_FACE}
.parity-card { --parity-contract: 7; font-family: ParityBody, sans-serif; }</style>
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
<h2 class="parity-card">Card</h2>
</article>
</template>`),
},
{ {
name: "a full-document composition hoisting a head stylesheet link", name: "a full-document composition hoisting a head stylesheet link",
files: cardHost(`<!doctype html><html><head> files: cardHost(`<!doctype html><html><head>
@@ -223,12 +263,13 @@ const subCompositions = (files: Record<string, string>) =>
* bootstrap script are injected by the player and the producer AROUND a mount, * bootstrap script are injected by the player and the producer AROUND a mount,
* never by `loadExternalCompositions`. Comparing them would compare harnesses. * never by `loadExternalCompositions`. Comparing them would compare harnesses.
* *
* Excluded for now, and deliberately NOT worked around: a templated * Nothing else is excluded. The templated-head-`<link>` divergence this file
* sub-composition whose document `<head>` carries a `<link>` the mount path * used to carve out is closed and gated by a fixture above; so is the
* hoists it unconditionally, the compiler only for a non-templated composition. * anonymous-host one. Two divergences remain ungated HERE rather than
* That is one of the live divergences U1 catalogued; closing it is a behaviour * unfixed an inline `<head>` script and the split between the CSS scope id
* decision for a later unit, not something a gate should paper over. The * and the script scope id both live in script bodies, and this contract
* non-templated shape IS covered above, where both paths agree. * carries no script-body field. Their gates are the unit suites in
* `packages/core/src/{compiler,runtime}`.
*/ */
function assembledContract(contract: ParityContract) { function assembledContract(contract: ParityContract) {
const { runtimeBootstrap: _runtime, variableBootstrap: _variables, ...assembled } = contract; const { runtimeBootstrap: _runtime, variableBootstrap: _variables, ...assembled } = contract;