diff --git a/packages/core/src/compiler/compositionAssembly.test.ts b/packages/core/src/compiler/compositionAssembly.test.ts index 882b934f12..f6a256a44b 100644 --- a/packages/core/src/compiler/compositionAssembly.test.ts +++ b/packages/core/src/compiler/compositionAssembly.test.ts @@ -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( `
`, ); @@ -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([]); }); diff --git a/packages/core/src/compiler/compositionAssembly.ts b/packages/core/src/compiler/compositionAssembly.ts index 201bd47318..7564c30200 100644 --- a/packages/core/src/compiler/compositionAssembly.ts +++ b/packages/core/src/compiler/compositionAssembly.ts @@ -205,12 +205,6 @@ export type NestedHostSkipReason = "circular composition reference" | "nesting d export interface NestedCompositionHost { 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 { @@ -243,7 +237,7 @@ export function enumerateNestedCompositionHosts { }); }); + it("collects an inline script instead of discarding it", () => { + // The loop had a `src` branch and no else, so an inline + // script was silently dropped on render while the mount path executed it. + const subCompWithHeadScript = ` + + + +
Hi
+`; + + 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 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 = ` + + + + +`; + + 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 = ` diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 350f7d9fa8..b7b68e8bb5 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -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({ + 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 ), then let per-instance host values override. if (readVariableDefaults && parseHostVariables && runtimeCompId) { - const mergedVariables = { - ...readVariableDefaults(compDoc.documentElement), - ...(innerRoot ? readVariableDefaults(innerRoot) : {}), - ...parseHostVariables(hostEl), - }; + const mergedVariables: Record = {}; + 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