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
@@ -1,19 +1,39 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
// @vitest-environment happy-dom
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
bundleToSingleHtml,
extractCompiledHtmlParityContract,
injectScriptsIntoHtml,
} from "@hyperframes/core/compiler";
// Deep import: the mount path is not part of core's published export map (it is
// bundled into the runtime IIFE, not imported by consumers). Same shape as
// engine/src/services/videoFrameExtractor.test.ts reaching into core's runtime.
import { loadExternalCompositions } from "../../../core/src/runtime/compositionLoader.js";
import { compileForRender } from "./htmlCompiler.js";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
const tempDirs: string[] = [];
beforeAll(() => {
// The mount path scopes CSS with CSS.escape, which the DOM stub omits.
if (typeof globalThis.CSS === "undefined") {
Object.defineProperty(globalThis, "CSS", { value: {}, configurable: true, writable: true });
}
if (typeof CSS.escape !== "function") {
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
}
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
document.head.innerHTML = "";
document.body.innerHTML = "";
delete (window as Window & { __hfVariablesByComp?: unknown }).__hfVariablesByComp;
delete (window as Window & { __timelines?: unknown }).__timelines;
vi.restoreAllMocks();
});
function project(files: Record<string, string>): string {
@@ -27,6 +47,39 @@ function project(files: Record<string, string>): string {
return dir;
}
type ParityContract = ReturnType<typeof extractCompiledHtmlParityContract>;
/**
* Mount the project the way the player does — parse `index.html` into the live
* document, serve its sub-compositions over a stubbed `fetch`, and let the
* runtime assemble them — then read the same contract off the resulting DOM.
*
* This is the third assembly path. Both compiler arms below run the same code
* up to `bundleToSingleHtml`, which is why a runtime-only regression (a mounted
* composition losing every `<style>` authored beside its root) stayed invisible
* to a green suite.
*/
async function mountContract(dir: string, indexHtml: string): Promise<ParityContract> {
const parsed = new DOMParser().parseFromString(indexHtml, "text/html");
document.head.innerHTML = parsed.head.innerHTML;
document.body.innerHTML = parsed.body.innerHTML;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) =>
Promise.resolve(new Response(readFileSync(join(dir, String(input)), "utf8"), { status: 200 })),
);
await loadExternalCompositions({
injectedStyles: [],
injectedScripts: [],
injectedLinks: [],
parseDimensionPx: (value: string | null) => (value ? `${value}px` : null),
// A mount that fails is not a parity result. Surface it instead of
// comparing the contract of an empty host against a compiled one.
onDiagnostic: ({ code, details }) => {
throw new Error(`mount diagnostic ${code}: ${JSON.stringify(details)}`);
},
});
return extractCompiledHtmlParityContract(`<!doctype html>${document.documentElement.outerHTML}`);
}
async function contracts(files: Record<string, string>) {
const dir = project(files);
const preview = await bundleToSingleHtml(dir);
@@ -42,6 +95,7 @@ async function contracts(files: Record<string, string>) {
return {
preview: extractCompiledHtmlParityContract(preview),
render: extractCompiledHtmlParityContract(servedRender),
mount: await mountContract(dir, files["index.html"]!),
};
}
@@ -94,3 +148,110 @@ describe("preview/render semantic compilation parity", () => {
expect(result.render).toEqual(result.preview);
});
});
const FONT_FACE = `@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }`;
const cardHost = (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-id="card" data-composition-src="compositions/card.html"
data-start="1" data-duration="3"></section>
</main>`),
"compositions/card.html": body,
});
/**
* Every fixture here mounts a sub-composition, because the mount path only runs
* for one. The set has to keep covering the shape that broke — see the
* assertion below, which fails if a refactor quietly drops it.
*/
const MOUNT_PARITY_FIXTURES: { name: string; files: Record<string, string> }[] = [
{
name: "assets authored as siblings of the composition root",
files: cardHost(`<template id="card-template">
<style>${FONT_FACE}
.parity-card { --parity-contract: 3; 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: "assets authored inside the composition root",
files: cardHost(`<template id="card-template">
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
<style>${FONT_FACE}
.parity-card { --parity-contract: 4; font-family: ParityBody, sans-serif; }</style>
<h2 class="parity-card">Card</h2>
</article>
</template>`),
},
{
name: "a full-document composition hoisting a head stylesheet link",
files: cardHost(`<!doctype html><html><head>
<link rel="preconnect" href="https://fonts.example.com" />
<style>${FONT_FACE}
.parity-card { --parity-contract: 5; font-family: ParityBody, sans-serif; }</style>
</head><body>
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
<h2 class="parity-card">Card</h2>
</article>
</body></html>`),
},
];
/** True when a sub-composition authors a `<style>`/`<script>` outside its root. */
function hasRootSiblingAssets(subCompositionHtml: string): boolean {
const doc = new DOMParser().parseFromString(subCompositionHtml, "text/html");
const template = doc.querySelector("template");
const scope: ParentNode = template ? template.content : doc.body;
const root = scope.querySelector("[data-composition-id]");
const assets = Array.from(scope.querySelectorAll("style, script"));
return assets.length > 0 && assets.some((asset) => !root?.contains(asset));
}
const subCompositions = (files: Record<string, string>) =>
Object.entries(files)
.filter(([path]) => path !== "index.html")
.map(([, content]) => content);
/**
* The runtime's assembly is not a compiler, so two contract fields are outside
* what this arm gates, and are asserted rather than compared:
*
* - `runtimeBootstrap` / `variableBootstrap` — the runtime IIFE and the variable
* 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.
*/
function assembledContract(contract: ParityContract) {
const { runtimeBootstrap: _runtime, variableBootstrap: _variables, ...assembled } = contract;
return assembled;
}
describe("mount/compile assembly parity", () => {
it("keeps a fixture set that still covers the shape the mount path used to drop", () => {
expect(MOUNT_PARITY_FIXTURES.length).toBeGreaterThan(0);
const siblingShaped = MOUNT_PARITY_FIXTURES.filter((fixture) =>
subCompositions(fixture.files).some(hasRootSiblingAssets),
);
expect(siblingShaped.map((fixture) => fixture.name)).not.toHaveLength(0);
});
it.each(MOUNT_PARITY_FIXTURES)(
"assembles $name identically on all three paths",
async ({ files }) => {
const result = await contracts(files);
expect(result.render).toEqual(result.preview);
expect(assembledContract(result.mount)).toEqual(assembledContract(result.preview));
expect(result.mount.runtimeBootstrap).toBe(false);
expect(result.mount.variableBootstrap).toBe(false);
},
);
});