mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:40:58 +00:00
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:
@@ -206,7 +206,7 @@ describe("enumerateNestedCompositionHosts", () => {
|
||||
return host;
|
||||
};
|
||||
|
||||
it("enumerates nested hosts with their ancestry", () => {
|
||||
it("enumerates every nested host in document order", () => {
|
||||
const host = assembled(
|
||||
`<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"]);
|
||||
|
||||
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(skipped).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -205,12 +205,6 @@ export type NestedHostSkipReason = "circular composition reference" | "nesting d
|
||||
export interface NestedCompositionHost<TElement> {
|
||||
host: TElement;
|
||||
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> {
|
||||
@@ -243,7 +237,7 @@ export function enumerateNestedCompositionHosts<TElement extends AssemblyAttribu
|
||||
skipped.push({ src, reason: "nesting depth exceeded" });
|
||||
continue;
|
||||
}
|
||||
hosts.push({ host: nestedHost, src, ancestry: [...ancestry, src] });
|
||||
hosts.push({ host: nestedHost, src });
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const subComp = `<!doctype html>
|
||||
<html><head>
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
rewriteInlineStyleAssetUrls,
|
||||
type AssetExists,
|
||||
} from "./rewriteSubCompPaths";
|
||||
import { queryByAttr } from "../utils/cssSelector";
|
||||
import {
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
} from "./compositionScoping";
|
||||
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
||||
import { enumerateNestedCompositionHosts, planCompositionAssembly } from "./compositionAssembly";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interface
|
||||
@@ -141,8 +141,6 @@ function defaultBuildScopeSelector(compId: string): string {
|
||||
return `[data-composition-id="${escaped}"]`;
|
||||
}
|
||||
|
||||
const MAX_SUB_COMPOSITION_DEPTH = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -244,21 +242,21 @@ export function inlineSubCompositions(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep structural flattening tied to an exact mount-id match. A template
|
||||
// may intentionally use a different local id (for example, a
|
||||
// `captions-comp` host mounting a `captions` template); flattening that
|
||||
// fallback root changes the compiled DOM and can invalidate selectors and
|
||||
// regression goldens. Discover it separately so script timeline
|
||||
// registration can still map the authored id onto the runtime mount id.
|
||||
const innerRoot = compId
|
||||
? queryByAttr(contentDoc, "data-composition-id", compId)
|
||||
: contentDoc.querySelector("[data-composition-id]");
|
||||
const authoredCompositionRoot = innerRoot ?? contentDoc.querySelector("[data-composition-id]");
|
||||
const inferredCompId =
|
||||
authoredCompositionRoot?.getAttribute("data-composition-id")?.trim() || "";
|
||||
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
|
||||
const scopeCompId = compId || inferredCompId;
|
||||
const scriptCompositionId = inferredCompId || scopeCompId;
|
||||
// Which node is the composition root, which id its CSS scopes to, which
|
||||
// id its scripts scope to, where its assets come from and in what order —
|
||||
// every one of those is decided by the shared assembly module, so the mount
|
||||
// path in runtime/compositionLoader.ts decides them the same way.
|
||||
const plan = planCompositionAssembly<Element>({
|
||||
contentNode: contentDoc,
|
||||
head: compDoc.head,
|
||||
documentElement: compDoc.documentElement,
|
||||
hasTemplate: Boolean(contentRoot),
|
||||
compositionId: compId,
|
||||
});
|
||||
const innerRoot = plan.innerRoot;
|
||||
const authoredRootId = plan.authoredRootId;
|
||||
const scopeCompId = plan.authoredCompositionId || "";
|
||||
const scriptCompositionId = plan.scriptCompositionId || "";
|
||||
const runtimeScope = runtimeCompId ? buildScopeSelector(runtimeCompId) : "";
|
||||
|
||||
// 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
|
||||
// synthetic <html>), then let per-instance host values override.
|
||||
if (readVariableDefaults && parseHostVariables && runtimeCompId) {
|
||||
const mergedVariables = {
|
||||
...readVariableDefaults(compDoc.documentElement),
|
||||
...(innerRoot ? readVariableDefaults(innerRoot) : {}),
|
||||
...parseHostVariables(hostEl),
|
||||
};
|
||||
const mergedVariables: Record<string, unknown> = {};
|
||||
for (const carrier of plan.variableDefaultCarriers) {
|
||||
Object.assign(mergedVariables, readVariableDefaults(carrier));
|
||||
}
|
||||
Object.assign(mergedVariables, parseHostVariables(hostEl));
|
||||
if (Object.keys(mergedVariables).length > 0) {
|
||||
variablesByComp[runtimeCompId] = mergedVariables;
|
||||
}
|
||||
@@ -295,47 +293,35 @@ export function inlineSubCompositions(
|
||||
: css;
|
||||
};
|
||||
|
||||
// When a sub-composition is a full HTML document (no <template>), styles
|
||||
// and scripts in <head> are not part of contentDoc (which only has body
|
||||
// content). Extract them so backgrounds, positioning, fonts, and library
|
||||
// scripts (e.g. GSAP CDN) are not silently dropped.
|
||||
if (!contentRoot && compDoc.head) {
|
||||
for (const s of [...compDoc.head.querySelectorAll("style")]) {
|
||||
styles.push(scopeSubStyle(s.textContent || ""));
|
||||
}
|
||||
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
||||
const externalSrc = resolveSubAssetPath(s.getAttribute("src"));
|
||||
if (externalSrc) {
|
||||
if (!externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
// <link> hoisting is unconditional. A templated sub-composition's webfont
|
||||
// link is as load-bearing as a non-templated one's, and the mount path has
|
||||
// always hoisted both; gating this on `!contentRoot` dropped a templated
|
||||
// composition's font from the render while preview kept it.
|
||||
for (const link of plan.linkSources) {
|
||||
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
|
||||
for (const s of [...contentDoc.querySelectorAll("style")]) {
|
||||
styles.push(scopeSubStyle(s.textContent || ""));
|
||||
s.remove();
|
||||
// Head-sourced assets come first: a non-templated sub-composition's <head>
|
||||
// carries its backgrounds, positioning and fonts, and a <head> library tag
|
||||
// (GSAP from a CDN) has to run before the content scripts calling into it.
|
||||
for (const styleEl of plan.styleSources) {
|
||||
styles.push(scopeSubStyle(styleEl.textContent || ""));
|
||||
styleEl.remove();
|
||||
}
|
||||
|
||||
// Extract scripts from content
|
||||
for (const s of [...contentDoc.querySelectorAll("script")]) {
|
||||
const externalSrc = resolveSubAssetPath(s.getAttribute("src"));
|
||||
// Head- and content-sourced scripts take the same branch. The head loop
|
||||
// used to handle only `src`, so an inline <head> script was silently
|
||||
// discarded on render while the mount path executed it.
|
||||
for (const scriptEl of plan.scriptSources) {
|
||||
const externalSrc = resolveSubAssetPath(scriptEl.getAttribute("src"));
|
||||
if (externalSrc) {
|
||||
if (!externalScriptSrcs.includes(externalSrc)) {
|
||||
externalScriptSrcs.push(externalSrc);
|
||||
@@ -344,18 +330,18 @@ export function inlineSubCompositions(
|
||||
} else {
|
||||
const wrappedScript = scriptCompositionId
|
||||
? wrapScopedCompositionScript(
|
||||
s.textContent || "",
|
||||
scriptEl.textContent || "",
|
||||
scriptCompositionId,
|
||||
scriptErrorLabel,
|
||||
runtimeScope || undefined,
|
||||
runtimeCompId || scopeCompId || scriptCompositionId,
|
||||
authoredRootId,
|
||||
)
|
||||
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
|
||||
: wrapInlineScriptWithErrorBoundary(scriptEl.textContent || "", scriptErrorLabel);
|
||||
scripts.push(wrappedScript);
|
||||
scriptItems.push({ kind: "inline", content: wrappedScript });
|
||||
}
|
||||
s.remove();
|
||||
scriptEl.remove();
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (flattenInnerRoot) {
|
||||
const prepared = flattenInnerRoot(innerRoot);
|
||||
if (!compId && inferredCompId) {
|
||||
if (!compId && scopeCompId) {
|
||||
// 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
|
||||
@@ -416,7 +402,7 @@ export function inlineSubCompositions(
|
||||
// (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);
|
||||
prepared.setAttribute("data-composition-id", scopeCompId);
|
||||
}
|
||||
hostEl.innerHTML = prepared.outerHTML || "";
|
||||
} else {
|
||||
@@ -441,18 +427,12 @@ export function inlineSubCompositions(
|
||||
hostEl.removeAttribute("data-composition-src");
|
||||
|
||||
const nestedAncestry = [...ancestry, src];
|
||||
for (const nestedHost of [...hostEl.querySelectorAll("[data-composition-src]")]) {
|
||||
const nestedSrc = nestedHost.getAttribute("data-composition-src");
|
||||
if (!nestedSrc) continue;
|
||||
if (nestedAncestry.includes(nestedSrc)) {
|
||||
onMissingComposition?.(nestedSrc, "circular composition reference");
|
||||
continue;
|
||||
}
|
||||
if (nestedAncestry.length >= MAX_SUB_COMPOSITION_DEPTH) {
|
||||
onMissingComposition?.(nestedSrc, "nesting depth exceeded");
|
||||
continue;
|
||||
}
|
||||
queue.push({ element: nestedHost, ancestry: nestedAncestry });
|
||||
const nested = enumerateNestedCompositionHosts(hostEl, nestedAncestry);
|
||||
for (const skipped of nested.skipped) {
|
||||
onMissingComposition?.(skipped.src, skipped.reason);
|
||||
}
|
||||
for (const nestedHost of nested.hosts) {
|
||||
queue.push({ element: nestedHost.host, 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 () => {
|
||||
// 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.
|
||||
it("scopes an anonymous host's composition to the id its own content declares", async () => {
|
||||
// A host naming no composition id used to mount the fetched content WHOLE:
|
||||
// unflattened, and with its CSS injected unscoped, so the composition's
|
||||
// rules landed on the host document at large. The compiler has always
|
||||
// fallen back to the first root declared in the content, scoped to it, and
|
||||
// restored the id that flattening strips. The mount path does the same now.
|
||||
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">
|
||||
<style>.label { color: rgb(1, 2, 3); }</style>
|
||||
<div data-composition-id="scoped-text" data-width="1080" data-height="1920">
|
||||
<div class="label">Scoped Text Should Stay Styled</div>
|
||||
</div>
|
||||
@@ -1243,17 +1240,76 @@ describe("loadExternalCompositions", () => {
|
||||
|
||||
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.
|
||||
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.
|
||||
// Flattened like every other mount, with the declared id restored so the
|
||||
// composition's scoped CSS and self-referencing queries still resolve.
|
||||
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(
|
||||
"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";');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { planCompositionAssembly } from "../compiler/compositionAssembly";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||
import { markFlattenedInnerRoot } from "./flattenedRoot";
|
||||
import {
|
||||
@@ -383,12 +384,12 @@ async function mountCompositionContent(params: {
|
||||
injectedScripts: HTMLScriptElement[];
|
||||
injectedLinks: HTMLLinkElement[];
|
||||
parseDimensionPx: (value: string | null) => string | null;
|
||||
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
|
||||
headStyles?: HTMLStyleElement[];
|
||||
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
||||
headScripts?: HTMLScriptElement[];
|
||||
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
|
||||
headLinks?: HTMLLinkElement[];
|
||||
/**
|
||||
* The parsed document's `<head>`, when the composition was loaded as a full
|
||||
* HTML document. What comes out of it is the shared assembly module's call:
|
||||
* styles and scripts only for a non-templated composition, links always.
|
||||
*/
|
||||
head?: ParentNode | null;
|
||||
/**
|
||||
* Defaults extracted from the sub-composition's own
|
||||
* `<html data-composition-variables="...">` attribute. Layered under the
|
||||
@@ -403,43 +404,51 @@ async function mountCompositionContent(params: {
|
||||
details: Record<string, string | number | boolean | null | string[]>;
|
||||
}) => void;
|
||||
}): Promise<void> {
|
||||
let innerRoot: HTMLElement | null = null;
|
||||
if (params.authoredCompositionId) {
|
||||
const candidateRoots = Array.from(
|
||||
params.sourceNode.querySelectorAll<HTMLElement>("[data-composition-id]"),
|
||||
);
|
||||
innerRoot =
|
||||
candidateRoots.find(
|
||||
(candidate) =>
|
||||
candidate instanceof HTMLElement &&
|
||||
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
|
||||
) ?? null;
|
||||
}
|
||||
// Which node is the composition root, which id its CSS scopes to, which id
|
||||
// its scripts scope to, where its assets come from and in what order: the
|
||||
// shared assembly module answers all of it, so this path and the compiler's
|
||||
// inlineSubCompositions agree by construction. Notably, an ANONYMOUS host
|
||||
// (one naming no composition id) now falls back to the first root declared
|
||||
// in the content and mounts scoped to it — mounting the content whole left
|
||||
// its CSS unscoped and leaking into the host document.
|
||||
const plan = planCompositionAssembly<Element>({
|
||||
contentNode: params.sourceNode,
|
||||
head: params.head,
|
||||
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 authoredScopeCompositionId =
|
||||
innerRoot?.getAttribute("data-composition-id")?.trim() || params.authoredCompositionId || null;
|
||||
const runtimeScopeCompositionId =
|
||||
params.runtimeCompositionId || authoredScopeCompositionId || null;
|
||||
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
|
||||
const authoredScopeCompositionId = plan.authoredCompositionId;
|
||||
// Scripts follow the id the CONTENT declares, CSS the id the HOST asked for.
|
||||
// They differ only when a host names an id no root in the content declares,
|
||||
// where collapsing them breaks a script's own
|
||||
// `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
|
||||
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
|
||||
: undefined;
|
||||
|
||||
if (params.headLinks) {
|
||||
for (const link of params.headLinks) {
|
||||
const rawHref = (link.getAttribute("href") || "").trim();
|
||||
if (!rawHref) continue;
|
||||
const href = params.compositionUrl ? new URL(rawHref, params.compositionUrl).href : rawHref;
|
||||
if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue;
|
||||
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
|
||||
const clonedLink = link.cloneNode(true) as HTMLLinkElement;
|
||||
clonedLink.href = href;
|
||||
document.head.appendChild(clonedLink);
|
||||
params.injectedLinks.push(clonedLink);
|
||||
}
|
||||
for (const link of plan.linkSources) {
|
||||
const rawHref = (link.getAttribute("href") || "").trim();
|
||||
if (!rawHref) continue;
|
||||
const href = params.compositionUrl ? new URL(rawHref, params.compositionUrl).href : rawHref;
|
||||
if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue;
|
||||
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
|
||||
const clonedLink = link.cloneNode(true);
|
||||
if (!(clonedLink instanceof HTMLLinkElement)) continue;
|
||||
clonedLink.href = href;
|
||||
document.head.appendChild(clonedLink);
|
||||
params.injectedLinks.push(clonedLink);
|
||||
}
|
||||
|
||||
const injectScopedStyles = (styleEls: Iterable<HTMLStyleElement>): void => {
|
||||
const injectScopedStyles = (styleEls: Iterable<Element>): void => {
|
||||
for (const style of styleEls) {
|
||||
const clonedStyle = style.cloneNode(true);
|
||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
||||
@@ -460,18 +469,13 @@ async function mountCompositionContent(params: {
|
||||
params.injectedStyles.push(clonedStyle);
|
||||
}
|
||||
};
|
||||
// Inject <head> styles from non-template sub-compositions first (they define
|
||||
// element styles like backgrounds and positioning that the composition needs),
|
||||
// then the content styles.
|
||||
if (params.headStyles) injectScopedStyles(params.headStyles);
|
||||
// Collect from `sourceNode`, not the composition root: the canonical authored
|
||||
// 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")));
|
||||
// Already in injection order: <head> styles from a non-template composition
|
||||
// first (they define backgrounds and positioning the composition needs), then
|
||||
// the content's — including the ones authored as SIBLINGS of the composition
|
||||
// root, the shape whose omission dropped a mounted composition's stylesheet.
|
||||
injectScopedStyles(plan.styleSources);
|
||||
|
||||
const toPendingScript = (script: HTMLScriptElement): PendingScript | null => {
|
||||
const toPendingScript = (script: Element): PendingScript | null => {
|
||||
const type = script.getAttribute("type")?.trim() ?? "";
|
||||
const src = script.getAttribute("src")?.trim() ?? "";
|
||||
if (src) {
|
||||
@@ -484,15 +488,12 @@ async function mountCompositionContent(params: {
|
||||
}
|
||||
const content = script.textContent?.trim() ?? "";
|
||||
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
|
||||
// must execute before the content scripts that call into them.
|
||||
const scriptPayloads = [
|
||||
...(params.headScripts ?? []),
|
||||
...Array.from(params.sourceNode.querySelectorAll<HTMLScriptElement>("script")),
|
||||
]
|
||||
// Already in execution order: <head> scripts first (a GSAP CDN tag in a
|
||||
// non-template sub-comp) so they run before the content scripts calling in.
|
||||
const scriptPayloads = plan.scriptSources
|
||||
.map(toPendingScript)
|
||||
.filter((payload): payload is PendingScript => payload !== null);
|
||||
|
||||
@@ -509,6 +510,13 @@ async function mountCompositionContent(params: {
|
||||
params.host.setAttribute("data-timeline-locked", "");
|
||||
}
|
||||
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);
|
||||
params.host.appendChild(flattenedRoot);
|
||||
} else if (params.hasTemplate) {
|
||||
@@ -689,23 +697,6 @@ export async function loadExternalCompositions(
|
||||
)
|
||||
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
|
||||
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({
|
||||
host,
|
||||
authoredCompositionId,
|
||||
@@ -719,9 +710,11 @@ export async function loadExternalCompositions(
|
||||
injectedScripts: params.injectedScripts,
|
||||
injectedLinks: params.injectedLinks,
|
||||
parseDimensionPx: params.parseDimensionPx,
|
||||
headStyles,
|
||||
headScripts,
|
||||
headLinks,
|
||||
// A non-templated composition's <head> carries critical CSS
|
||||
// (backgrounds, positioning, fonts) and library scripts; every
|
||||
// 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
|
||||
// sub-comp that declares on its `[data-composition-id]` root div (the
|
||||
// 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 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) => ({
|
||||
"index.html":
|
||||
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>
|
||||
</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",
|
||||
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,
|
||||
* never by `loadExternalCompositions`. Comparing them would compare harnesses.
|
||||
*
|
||||
* Excluded for now, and deliberately NOT worked around: a templated
|
||||
* sub-composition whose document `<head>` carries a `<link>` — the mount path
|
||||
* hoists it unconditionally, the compiler only for a non-templated composition.
|
||||
* That is one of the live divergences U1 catalogued; closing it is a behaviour
|
||||
* decision for a later unit, not something a gate should paper over. The
|
||||
* non-templated shape IS covered above, where both paths agree.
|
||||
* Nothing else is excluded. The templated-head-`<link>` divergence this file
|
||||
* used to carve out is closed and gated by a fixture above; so is the
|
||||
* anonymous-host one. Two divergences remain ungated HERE rather than
|
||||
* unfixed — an inline `<head>` script and the split between the CSS scope id
|
||||
* and the script scope id both live in script bodies, and this contract
|
||||
* carries no script-body field. Their gates are the unit suites in
|
||||
* `packages/core/src/{compiler,runtime}`.
|
||||
*/
|
||||
function assembledContract(contract: ParityContract) {
|
||||
const { runtimeBootstrap: _runtime, variableBootstrap: _variables, ...assembled } = contract;
|
||||
|
||||
Reference in New Issue
Block a user