Skip to content

Commit f56b4c8

Browse files
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)
1 parent 0d33238 commit f56b4c8

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

packages/core/src/compiler/htmlBundler.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,22 @@ export async function bundleToSingleHtml(
440440
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
441441
: contentDoc.querySelector("[data-composition-id]");
442442

443+
// When a sub-composition is a full HTML document (no <template>), styles
444+
// and scripts in <head> are not part of contentDoc (which only has body
445+
// content). Extract them so backgrounds, positioning, fonts, and library
446+
// scripts (e.g. GSAP CDN) are not silently dropped.
447+
if (!contentRoot && compDoc.head) {
448+
for (const s of [...compDoc.head.querySelectorAll("style")]) {
449+
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
450+
}
451+
for (const s of [...compDoc.head.querySelectorAll("script")]) {
452+
const externalSrc = (s.getAttribute("src") || "").trim();
453+
if (externalSrc && !compExternalScriptSrcs.includes(externalSrc)) {
454+
compExternalScriptSrcs.push(externalSrc);
455+
}
456+
}
457+
}
458+
443459
for (const s of [...contentDoc.querySelectorAll("style")]) {
444460
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
445461
s.remove();

packages/core/src/runtime/compositionLoader.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ async function mountCompositionContent(params: {
8181
injectedStyles: HTMLStyleElement[];
8282
injectedScripts: HTMLScriptElement[];
8383
parseDimensionPx: (value: string | null) => string | null;
84+
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
85+
headStyles?: HTMLStyleElement[];
86+
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
87+
headScripts?: HTMLScriptElement[];
8488
onDiagnostic?: (payload: {
8589
code: string;
8690
details: Record<string, string | number | boolean | null | string[]>;
@@ -98,6 +102,17 @@ async function mountCompositionContent(params: {
98102
}
99103
const contentNode = innerRoot ?? params.sourceNode;
100104

105+
// Inject <head> styles from non-template sub-compositions first (they define
106+
// element styles like backgrounds and positioning that the composition needs).
107+
if (params.headStyles) {
108+
for (const style of params.headStyles) {
109+
const clonedStyle = style.cloneNode(true);
110+
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
111+
document.head.appendChild(clonedStyle);
112+
params.injectedStyles.push(clonedStyle);
113+
}
114+
}
115+
101116
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
102117
for (const style of styles) {
103118
const clonedStyle = style.cloneNode(true);
@@ -106,8 +121,27 @@ async function mountCompositionContent(params: {
106121
params.injectedStyles.push(clonedStyle);
107122
}
108123

124+
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
125+
// then content scripts. Head scripts must execute before content scripts.
126+
const headScriptPayloads: PendingScript[] = [];
127+
if (params.headScripts) {
128+
for (const script of params.headScripts) {
129+
const scriptType = script.getAttribute("type")?.trim() ?? "";
130+
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
131+
if (scriptSrc) {
132+
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
133+
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
134+
} else {
135+
const scriptText = script.textContent?.trim() ?? "";
136+
if (scriptText) {
137+
headScriptPayloads.push({ kind: "inline", content: scriptText, type: scriptType });
138+
}
139+
}
140+
}
141+
}
142+
109143
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
110-
const scriptPayloads: PendingScript[] = [];
144+
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
111145
for (const script of scripts) {
112146
const scriptType = script.getAttribute("type")?.trim() ?? "";
113147
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
@@ -287,6 +321,18 @@ export async function loadExternalCompositions(
287321
)
288322
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
289323
const sourceNode = template ? template.content : doc.body;
324+
// When loading a non-template sub-composition (full HTML document),
325+
// extract <style> and <script> elements from the parsed document's
326+
// <head>. These contain critical CSS (backgrounds, positioning, fonts)
327+
// and library scripts (e.g. GSAP CDN) that would otherwise be lost
328+
// because mountCompositionContent only looks inside the composition
329+
// root element.
330+
const headStyles = !template
331+
? Array.from(doc.head.querySelectorAll<HTMLStyleElement>("style"))
332+
: undefined;
333+
const headScripts = !template
334+
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
335+
: undefined;
290336
await mountCompositionContent({
291337
host,
292338
hostCompositionId,
@@ -298,6 +344,8 @@ export async function loadExternalCompositions(
298344
injectedStyles: params.injectedStyles,
299345
injectedScripts: params.injectedScripts,
300346
parseDimensionPx: params.parseDimensionPx,
347+
headStyles,
348+
headScripts,
301349
onDiagnostic: params.onDiagnostic,
302350
});
303351
} catch (error) {

0 commit comments

Comments
 (0)