fix(core): stop dropping a mounted composition styles, and gate the divergence (#3094)

## Why

A composition mounted as a sub-composition lost its entire stylesheet and scripts whenever they were authored as siblings of the composition root inside its `<template>`. That shape is legal and common, so three catalog components — `oversized-cursor`, `device-frame-stage`, `touch-indicator` — rendered **completely unstyled** in the live preview.

`oversized-cursor` drew its pointer at 1280px against an authored `7cqw` (~134px at 1920), because `width: 7cqw` was never declared at all. Confirmed in the mounted document, where only the host's own `<style>` was present.

The rendered video was correct the entire time. This was a preview-versus-render divergence, and it survived a fully green test suite.

## How

**The fix.** `mountCompositionContent` collected assets from the composition root element, so sibling nodes were invisible to it. It now collects from the source node — a superset of the root, and the single point every mount path routes through (external fetch, inline template, nested). It also strips the mounted *clone* rather than the source: the previous code removed extracted nodes from the node it was handed, which on the inline-template path is a live `<template>` still in the document, so a remount would have found it emptied.

**Why nothing caught it.** Every CLI gate — `check`, `lint`, `validate` — reaches the compiler path through `bundleToSingleHtml`, and the compiler always collected from the whole template. Nothing in the CLI exercises the mount path, which is reachable only through the player and Studio. The repo's own parity test assembled a fixture two ways and deep-equalled a contract across them, but **both arms were static-compiler paths** — which is exactly why the runtime could drift unnoticed.

**The gate.** A third arm mounts the same fixture through `loadExternalCompositions` and extracts the same contract. Three fixtures run through all three arms, one authoring its assets as root siblings — the shape that broke. `authoredStyleSignatures` was already in the contract and is exactly the signal that was missing, so no contract field was added.

**The owner.** Both paths answer the same questions — which nodes are a composition's assets, in what order its scripts run, how its CSS is scoped, which head elements hoist, how nested hosts are discovered, which element carries variable defaults. They now have one module to answer them from. It holds decisions only, never I/O: the two paths differ at their boundary in ways that are essential (Node + linkedom + synchronous + strings; browser + fetch + live DOM + script *execution*), and the runtime ships as a bundle to a CDN, so anything it can reach is weight and risk. Hence zero imports, a structural input type rather than `Document`, and a test asserting the import surface stays empty.

Routing both paths through that module is deliberately **not** in this PR — it changes behaviour in four places (below) and belongs where each can be judged and reverted on its own.

## Test plan

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

Every claim here was verified in both directions rather than assumed.

The fix's regression test fails on pre-fix code and passes after — run both ways. The parity arm was proven able to fail: with the fix reverted, the sibling fixture fails and names the composition's own scoped selector against an empty list, while the other two fixtures stay green, so the arm is targeted rather than blanket-red. Restored, 7/7 pass.

`bun run lint` exits 0. Core: 1690 tests passing, plus `typecheck:runtime` and `lint:runtime-preview-guards` clean. Producer: 571 tests passing. The shared module's own defect was reproduced by mutation — collecting from the composition root instead of the whole template fails three of its 17 tests, including the sibling case.

## Found while doing this, not fixed here

Deriving the shared decisions surfaced **four more live divergences**, none of them the reported bug, each a behaviour change to decide deliberately:

- The compiler silently drops inline `<head>` scripts — it handles the `src` case and has no `else` — while the runtime executes them.
- `<link>` hoisting is conditional on render and unconditional on mount, so a templated sub-composition's webfont link is dropped in video and kept in preview. This one reproduces under the new parity arm and is explicitly excluded from its contract, with the reason recorded in the file.
- For a host naming no id, the compiler falls back to the first declared composition and scopes to it; the runtime mounts the content whole, unflattened and unscoped.
- The compiler keeps two scope ids, CSS and scripts, so a script's self-referencing query resolves when a host names an id the content does not declare; the runtime keeps one.

Separately: the mount path **does not recurse at all**, so a sub-composition containing its own `data-composition-src` is silently dropped in live preview. The compiler has a dedicated recursive-discovery suite; the runtime has no nesting, circularity or depth coverage.

Each is recorded with its evidence in the commit messages here, and sequenced so the behaviour-changing ones land separately, after this gate exists to catch a mistake in them.

## Not covered

This does not heal the published docs by itself. Previews load `@hyperframes/player` unpinned, but the player bakes a version-pinned core runtime URL at build time, and core and player publish in lockstep — so the live catalog only recovers after both ship. There is no hotfix path short of a release.
This commit is contained in:
Miguel Ángel
2026-08-07 14:28:03 -07:00
committed by GitHub
parent 172311e95e
commit 8d9db3df73
5 changed files with 766 additions and 62 deletions
@@ -0,0 +1,269 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
enumerateNestedCompositionHosts,
MAX_SUB_COMPOSITION_DEPTH,
planCompositionAssembly,
type CompositionAssemblyPlan,
} from "./compositionAssembly";
function parseComposition(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}
/**
* Drive the module the way both real paths do: find the `<template>`, fall back
* to `<body>`, and hand it the document's head and root element. The explicit
* `<Element>` type argument is the whole point of the structural inputs — a
* `DocumentFragment` and an `HTMLElement` both satisfy them without a cast.
*/
function planFor(html: string, compositionId: string | null): CompositionAssemblyPlan<Element> {
const doc = parseComposition(html);
const template = doc.querySelector("template");
return planCompositionAssembly<Element>({
contentNode: template ? template.content : doc.body,
head: doc.head,
documentElement: doc.documentElement,
hasTemplate: Boolean(template),
compositionId,
});
}
const SIBLING_ASSETS = `
<html data-composition-variables='{"tone":"dark"}'>
<head></head>
<body>
<template id="scene-template">
<style id="sibling-style">#root { container-type: size; }</style>
<div data-composition-id="scene" id="root" data-width="1920" data-height="1080"></div>
<script id="sibling-script">window.__a = 1;</script>
</template>
</body>
</html>`;
const ROOT_INTERNAL_ASSETS = `
<html>
<head></head>
<body>
<template id="scene-template">
<div data-composition-id="scene" id="root">
<style id="inner-style">#root { color: red; }</style>
<script id="inner-script">window.__b = 1;</script>
</div>
</template>
</body>
</html>`;
const BOTH_SHAPES = `
<html>
<head></head>
<body>
<template id="scene-template">
<style id="sibling-style">#root { container-type: size; }</style>
<div data-composition-id="scene" id="root">
<style id="inner-style">#root { color: red; }</style>
</div>
</template>
</body>
</html>`;
const FULL_DOCUMENT = `
<html data-composition-variables='{"tone":"dark"}'>
<head>
<link rel="preconnect" href="https://fonts.example">
<link rel="stylesheet" href="fonts.css">
<link rel="icon" href="favicon.ico">
<style id="head-style">body { background: black; }</style>
<script id="head-script" src="https://cdn.example/gsap.js"></script>
</head>
<body>
<div data-composition-id="scene" id="root" data-composition-variables='{"tone":"light"}'>
<style id="body-style">#root { color: red; }</style>
</div>
<script id="body-script">window.__c = 1;</script>
</body>
</html>`;
const ids = (elements: Element[]): string[] => elements.map((el) => el.getAttribute("id") || "");
describe("planCompositionAssembly", () => {
it("reports assets authored as siblings of the composition root inside <template>", () => {
// This is the original defect: collecting from the composition root alone
// dropped the whole stylesheet on mount while the render stayed correct.
const plan = planFor(SIBLING_ASSETS, "scene");
expect(ids(plan.styleSources)).toEqual(["sibling-style"]);
expect(ids(plan.scriptSources)).toEqual(["sibling-script"]);
expect(plan.styleSources[0]?.textContent).toContain("container-type: size");
});
it("reports assets authored inside the composition root", () => {
const plan = planFor(ROOT_INTERNAL_ASSETS, "scene");
expect(ids(plan.styleSources)).toEqual(["inner-style"]);
expect(ids(plan.scriptSources)).toEqual(["inner-script"]);
});
it("reports both shapes once each, in document order", () => {
const plan = planFor(BOTH_SHAPES, "scene");
expect(ids(plan.styleSources)).toEqual(["sibling-style", "inner-style"]);
});
it("reports body assets for a composition with no <template>", () => {
const plan = planFor(FULL_DOCUMENT, "scene");
expect(ids(plan.styleSources)).toContain("body-style");
expect(ids(plan.scriptSources)).toContain("body-script");
});
it("orders head-sourced assets before content-sourced ones", () => {
const plan = planFor(FULL_DOCUMENT, "scene");
expect(ids(plan.styleSources)).toEqual(["head-style", "body-style"]);
expect(ids(plan.scriptSources)).toEqual(["head-script", "body-script"]);
});
it("does not treat a templated composition's page head as an asset source", () => {
// The head belongs to the host page, not to the composition; only the
// non-templated (full-document) shape carries composition assets in <head>.
const plan = planFor(
SIBLING_ASSETS.replace("<head></head>", '<head><style id="page-style">a{}</style></head>'),
"scene",
);
expect(ids(plan.styleSources)).toEqual(["sibling-style"]);
});
it("hoists stylesheet and preconnect links from <head> and nothing else", () => {
const plan = planFor(FULL_DOCUMENT, "scene");
expect(plan.linkSources.map((link) => link.getAttribute("href"))).toEqual([
"https://fonts.example",
"fonts.css",
]);
});
it("identifies the composition from the host id, matching the root exactly", () => {
const plan = planFor(SIBLING_ASSETS, "scene");
expect(plan.innerRoot?.getAttribute("data-composition-id")).toBe("scene");
expect(plan.authoredCompositionId).toBe("scene");
expect(plan.scriptCompositionId).toBe("scene");
expect(plan.authoredRootId).toBe("root");
});
it("keeps CSS on the host id and scripts on the declared id when they disagree", () => {
// A `captions-comp` host mounting a `captions` template: there is no exact
// root match, so nothing is flattened, but the script's self-referencing
// querySelector still has to resolve against the id the content declares.
const plan = planFor(SIBLING_ASSETS, "scene-comp");
expect(plan.innerRoot).toBeNull();
expect(plan.authoredCompositionId).toBe("scene-comp");
expect(plan.scriptCompositionId).toBe("scene");
expect(plan.authoredRootId).toBeNull();
});
it("falls back to the first declared root for an anonymous host", () => {
const plan = planFor(SIBLING_ASSETS, null);
expect(plan.innerRoot?.getAttribute("id")).toBe("root");
expect(plan.authoredCompositionId).toBe("scene");
});
it("reports both the document element and the inner root as variable carriers", () => {
const plan = planFor(FULL_DOCUMENT, "scene");
expect(plan.variableDefaultCarriers).toHaveLength(2);
expect(plan.variableDefaultCarriers[0]?.tagName.toLowerCase()).toBe("html");
expect(plan.variableDefaultCarriers[1]?.getAttribute("id")).toBe("root");
// Precedence order, lowest first: the inner root's declaration wins.
expect(plan.variableDefaultCarriers[1]?.getAttribute("data-composition-variables")).toContain(
"light",
);
});
it("reports only the document element when the content declares no root", () => {
const plan = planFor(
`<html><head></head><body><template id="scene-template"><p>no root</p></template></body></html>`,
"scene",
);
expect(plan.innerRoot).toBeNull();
expect(plan.variableDefaultCarriers).toHaveLength(1);
expect(plan.authoredCompositionId).toBe("scene");
});
});
describe("enumerateNestedCompositionHosts", () => {
const assembled = (html: string): Element => {
const doc = parseComposition(`<html><body><div id="host">${html}</div></body></html>`);
const host = doc.getElementById("host");
if (!host) throw new Error("fixture host missing");
return host;
};
it("enumerates nested hosts with their ancestry", () => {
const host = assembled(
`<div data-composition-src="child-a.html"></div><div data-composition-src="child-b.html"></div>`,
);
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([]);
});
it("skips a src already in its own ancestry instead of recursing forever", () => {
const host = assembled(`<div data-composition-src="outer.html"></div>`);
const { hosts, skipped } = enumerateNestedCompositionHosts(host, ["outer.html"]);
expect(hosts).toEqual([]);
expect(skipped).toEqual([{ src: "outer.html", reason: "circular composition reference" }]);
});
it("stops at the depth cap", () => {
const host = assembled(`<div data-composition-src="deep.html"></div>`);
const atCap = Array.from({ length: MAX_SUB_COMPOSITION_DEPTH }, (_, i) => `level-${i}.html`);
const { hosts, skipped } = enumerateNestedCompositionHosts(host, atCap);
expect(hosts).toEqual([]);
expect(skipped).toEqual([{ src: "deep.html", reason: "nesting depth exceeded" }]);
});
it("ignores a host whose src attribute is empty", () => {
const host = assembled(`<div data-composition-src=""></div>`);
expect(enumerateNestedCompositionHosts(host, ["outer.html"])).toEqual({
hosts: [],
skipped: [],
});
});
});
describe("import surface", () => {
it("imports nothing that the CDN runtime bundle must not carry", () => {
// The runtime ships as an esbuild IIFE. A DOM, filesystem, or cross-package
// import here would ride along, so assert the module's own source rather
// than trusting a comment.
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "compositionAssembly.ts"), "utf8");
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
const specifiers = Array.from(code.matchAll(/\bfrom\s+["']([^"']+)["']/g)).map(
(match) => match[1] ?? "",
);
expect(specifiers).toEqual([]);
expect(code).not.toMatch(/\brequire\s*\(/);
// No ambient DOM or Node globals either — the structural inputs exist so
// this module never needs them.
expect(code).not.toMatch(/\b(document|window|globalThis|process|fetch)\s*[.[]/);
});
});
@@ -0,0 +1,250 @@
/**
* Composition assembly decisions — the questions both assembly paths answer.
*
* A composition is assembled twice in this repo: `compiler/inlineSubCompositions.ts`
* assembles it for rendering (Node, linkedom, synchronous, emits strings) and
* `runtime/compositionLoader.ts` assembles it for mounting (browser, fetch, live
* DOM, executes scripts). Their I/O is genuinely different; their *decisions* are
* not. This module owns the decisions:
*
* - which nodes are a composition's asset sources, and in what order
* - which `<head>` elements hoist into the host document
* - the composition's scope identity (CSS scope, script scope, authored root id)
* - which nodes carry the composition's declared variable defaults
* - how nested `data-composition-src` hosts are enumerated
*
* It is deliberately DOM-free, filesystem-free and network-free: the runtime is
* bundled by esbuild into the IIFE published to a CDN, so anything reachable
* from `runtime/entry.ts` ships to every viewer. It takes narrow structural
* types instead of `Document`/`Element` for the same reason `compositionScoping.ts`
* takes strings — so both a linkedom document and a live browser document
* satisfy it without an `as T` cast at either call site.
*/
const COMPOSITION_ID_ATTR = "data-composition-id";
const COMPOSITION_SRC_ATTR = "data-composition-src";
const COMPOSITION_ROOT_SELECTOR = `[${COMPOSITION_ID_ATTR}]`;
const COMPOSITION_HOST_SELECTOR = `[${COMPOSITION_SRC_ATTR}]`;
const STYLE_SELECTOR = "style";
const SCRIPT_SELECTOR = "script";
/**
* `<head>` links both paths hoist into the host document. Stylesheets carry
* webfonts a composition's CSS depends on; preconnects are their latency hint.
*/
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';
/**
* The compiler's nesting cap, enforced against the ancestry chain rather than a
* counter so a wide tree is not penalised for a deep sibling.
*/
export const MAX_SUB_COMPOSITION_DEPTH = 20;
// ---------------------------------------------------------------------------
// Structural inputs
// ---------------------------------------------------------------------------
/** An element this module only ever reads attributes from. */
export interface AssemblyAttributed {
getAttribute(name: string): string | null;
}
/** A node this module only ever searches with a CSS selector. */
export interface AssemblyQueryable<TElement> {
querySelectorAll(selectors: string): Iterable<TElement>;
}
export interface CompositionAssemblyInput<TElement extends AssemblyAttributed> {
/**
* The composition's content: a `<template>`'s content when the composition is
* templated, the parsed document's `<body>` otherwise.
*
* Asset sources are collected from the WHOLE content node, never from the
* composition root alone. The canonical authored shape puts `<style>`/`<script>`
* as SIBLINGS of the root inside `<template>`; scanning only the root dropped a
* composition's entire stylesheet on mount while its render stayed correct.
*/
contentNode: AssemblyQueryable<TElement>;
/**
* The parsed document's `<head>`, when the composition was loaded as a full
* HTML document. Omit it for an inline `<template>`, which has no head of its
* own.
*/
head?: AssemblyQueryable<TElement> | null;
/**
* The parsed document's `<html>` element, when there is one. It is the first
* variable-default carrier; see `variableDefaultCarriers`.
*/
documentElement?: TElement | null;
/**
* True when `contentNode` came from a `<template>`. A templated composition's
* `<head>` styles and scripts are not part of the composition — the template
* already carries everything it needs — so they are not collected.
*/
hasTemplate: boolean;
/**
* The composition id the host asks this composition to mount as, or `null` for
* an anonymous host.
*/
compositionId: string | null;
}
export interface CompositionAssemblyPlan<TElement extends AssemblyAttributed> {
/**
* The composition root inside `contentNode`. Matched on an EXACT id when the
* host names one — a template may intentionally use a different local id (a
* `captions-comp` host mounting a `captions` template), and flattening that
* fallback root changes the assembled DOM. Anonymous hosts fall back to the
* first root in the content.
*/
innerRoot: TElement | null;
/**
* The id CSS is scoped to: the host's id when it names one, otherwise the id
* declared inside the content.
*/
authoredCompositionId: string | null;
/**
* The id composition scripts are scoped to. It differs from
* `authoredCompositionId` only when a host names an id that no root inside the
* content declares: CSS stays on the host's id while scripts follow the id the
* content actually declares, so a script's self-referencing
* `querySelector('[data-composition-id="X"]')` still resolves.
*/
scriptCompositionId: string | null;
/** The `id` attribute authored on the composition root, if any. */
authoredRootId: string | null;
/** `<style>` sources in injection order: head-sourced first, then content. */
styleSources: TElement[];
/**
* `<script>` sources in execution order: head-sourced first, then content. A
* head script (a GSAP CDN tag in a non-templated composition) has to run
* before the content scripts that call into it.
*/
scriptSources: TElement[];
/** `<head>` links to hoist into the host document. */
linkSources: TElement[];
/**
* Nodes that may declare the composition's variable defaults, in precedence
* order — later wins. Full-document compositions declare on `<html>`;
* template/fragment compositions declare on the `[data-composition-id]` root
* div, because they have no `<html>` of their own. Callers read the declared
* defaults off each carrier and merge left to right.
*/
variableDefaultCarriers: TElement[];
}
// ---------------------------------------------------------------------------
// Decisions
// ---------------------------------------------------------------------------
function toArray<TElement>(items: Iterable<TElement> | null | undefined): TElement[] {
return items ? Array.from(items) : [];
}
/**
* Answer, for one composition, where its assets come from, in what order, and
* how it is identified. Pure: it reads attributes and runs selectors, and does
* not mutate, fetch, or touch a filesystem.
*/
export function planCompositionAssembly<TElement extends AssemblyAttributed>(
input: CompositionAssemblyInput<TElement>,
): CompositionAssemblyPlan<TElement> {
const { contentNode, head, documentElement, hasTemplate, compositionId } = input;
const compositionRoots = toArray(contentNode.querySelectorAll(COMPOSITION_ROOT_SELECTOR));
const innerRoot = compositionId
? (compositionRoots.find((root) => root.getAttribute(COMPOSITION_ID_ATTR) === compositionId) ??
null)
: (compositionRoots[0] ?? null);
// The id declared inside the content, even when the host asked for a
// different one. `innerRoot` is preferred so an exact match always wins.
const declaredCompositionId =
(innerRoot ?? compositionRoots[0])?.getAttribute(COMPOSITION_ID_ATTR)?.trim() || "";
// A templated composition's <head> belongs to its host page, not to it.
const assetHead = hasTemplate ? null : (head ?? null);
return {
innerRoot,
authoredCompositionId: compositionId || declaredCompositionId || null,
scriptCompositionId: declaredCompositionId || compositionId || null,
authoredRootId: innerRoot?.getAttribute("id")?.trim() || null,
styleSources: [
...toArray(assetHead?.querySelectorAll(STYLE_SELECTOR)),
...toArray(contentNode.querySelectorAll(STYLE_SELECTOR)),
],
scriptSources: [
...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
],
linkSources: toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
variableDefaultCarriers: [documentElement, innerRoot].filter(
(carrier): carrier is TElement => carrier != null,
),
};
}
// ---------------------------------------------------------------------------
// Nested hosts
// ---------------------------------------------------------------------------
export type NestedHostSkipReason = "circular composition reference" | "nesting depth exceeded";
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> {
hosts: NestedCompositionHost<TElement>[];
skipped: { src: string; reason: NestedHostSkipReason }[];
}
/**
* Enumerate the sub-composition hosts nested inside a composition that has just
* been assembled, refusing the two shapes that do not terminate.
*
* `ancestry` is the chain down to and including the assembled composition's own
* `src`, so a top-level host is enumerated with `[itsOwnSrc]`.
*/
export function enumerateNestedCompositionHosts<TElement extends AssemblyAttributed>(
assembledHost: AssemblyQueryable<TElement>,
ancestry: readonly string[],
): NestedCompositionHosts<TElement> {
const hosts: NestedCompositionHost<TElement>[] = [];
const skipped: { src: string; reason: NestedHostSkipReason }[] = [];
for (const nestedHost of assembledHost.querySelectorAll(COMPOSITION_HOST_SELECTOR)) {
const src = nestedHost.getAttribute(COMPOSITION_SRC_ATTR);
if (!src) continue;
if (ancestry.includes(src)) {
skipped.push({ src, reason: "circular composition reference" });
continue;
}
if (ancestry.length >= MAX_SUB_COMPOSITION_DEPTH) {
skipped.push({ src, reason: "nesting depth exceeded" });
continue;
}
hosts.push({ host: nestedHost, src, ancestry: [...ancestry, src] });
}
return { hosts, skipped };
}