fix(core): load head styles/scripts from non-template sub-compositions (#219)

## Summary

Fixes a bug where non-template sub-compositions (full HTML documents loaded via `data-composition-src`) lost all `<head>` styles and scripts. This affected **three code paths**:

1. **Runtime** (`compositionLoader.ts`) — browser preview via iframe fetch
2. **Bundler** (`htmlBundler.ts`) — studio preview HTML bundling (**this was causing the black preview**)
3. Producer fix is in PR #220

## What it fixes

**Eval prompt #25** (iris-wipe) renders entirely black in both the studio preview and rendered video because scene backgrounds (`#EF4444` red, `#3B82F6` blue), positioning, and the GSAP CDN script were all in `<head>` and silently dropped.

### Verified

Rebuilt core, started studio preview, fetched the bundled HTML from `/api/projects/iris-wipe/preview` — confirmed `#scene1 { background: #EF4444 }` and `.scene { position: absolute }` are now present in the output.

## Root cause

All three code paths did the same thing:
```js
const contentHtml = template ? template.innerHTML : bodyEl.innerHTML;
// ^ <head> content is already lost here
```

## Test plan

- [x] All 429 core tests pass
- [x] Studio preview endpoint returns correct bundled HTML with head styles included
- [x] `pnpm --filter @hyperframes/core build` succeeds

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-04-07 17:17:25 +02:00
committed by GitHub
parent 0d33238381
commit f56b4c8620
2 changed files with 65 additions and 1 deletions
+16
View File
@@ -440,6 +440,22 @@ export async function bundleToSingleHtml(
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
: contentDoc.querySelector("[data-composition-id]");
// 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")]) {
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
if (externalSrc && !compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
}
}
for (const s of [...contentDoc.querySelectorAll("style")]) {
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
s.remove();
+49 -1
View File
@@ -81,6 +81,10 @@ async function mountCompositionContent(params: {
injectedStyles: HTMLStyleElement[];
injectedScripts: HTMLScriptElement[];
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[];
onDiagnostic?: (payload: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
@@ -98,6 +102,17 @@ async function mountCompositionContent(params: {
}
const contentNode = innerRoot ?? params.sourceNode;
// Inject <head> styles from non-template sub-compositions first (they define
// element styles like backgrounds and positioning that the composition needs).
if (params.headStyles) {
for (const style of params.headStyles) {
const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
document.head.appendChild(clonedStyle);
params.injectedStyles.push(clonedStyle);
}
}
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
for (const style of styles) {
const clonedStyle = style.cloneNode(true);
@@ -106,8 +121,27 @@ async function mountCompositionContent(params: {
params.injectedStyles.push(clonedStyle);
}
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
// then content scripts. Head scripts must execute before content scripts.
const headScriptPayloads: PendingScript[] = [];
if (params.headScripts) {
for (const script of params.headScripts) {
const scriptType = script.getAttribute("type")?.trim() ?? "";
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
if (scriptSrc) {
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
} else {
const scriptText = script.textContent?.trim() ?? "";
if (scriptText) {
headScriptPayloads.push({ kind: "inline", content: scriptText, type: scriptType });
}
}
}
}
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
const scriptPayloads: PendingScript[] = [];
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
for (const script of scripts) {
const scriptType = script.getAttribute("type")?.trim() ?? "";
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
@@ -287,6 +321,18 @@ 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;
await mountCompositionContent({
host,
hostCompositionId,
@@ -298,6 +344,8 @@ export async function loadExternalCompositions(
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
parseDimensionPx: params.parseDimensionPx,
headStyles,
headScripts,
onDiagnostic: params.onDiagnostic,
});
} catch (error) {