fix(producer): hoist external CDN scripts from sub-compositions (#164)

## Summary

- **Fixes**: External `<script src="...">` tags in sub-compositions (e.g. GSAP TextPlugin, ScrollTrigger) were silently discarded during `hyperframes render`, causing `ReferenceError` at runtime
- **Root cause**: The producer's `inlineSubCompositions` skipped external scripts with `if (src) continue` but then removed them via the blanket `querySelectorAll("style, script").forEach(remove)` — they were never hoisted to the parent document
- **Fix**: Mirror the core bundler's (`htmlBundler.ts`) approach — collect external script `src` URLs, deduplicate against existing scripts in the parent, and inject as `<script>` tags before inline composition scripts

## How to reproduce

1. Create a sub-composition that uses GSAP TextPlugin:

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/TextPlugin.min.js"></script>
<script>
  gsap.registerPlugin(TextPlugin);
  tl.to("#text", { text: { value: "Hello" }, duration: 1 });
</script>
```

1. Run `hyperframes render`
2. Browser console shows: `[Compiler] Composition script failed scene-hook ReferenceError: TextPlugin is not defined`

## Test plan

- [x] Verify `hyperframes render` on a project using TextPlugin in a sub-composition no longer errors
- [x] Verify external scripts are deduped (same CDN URL in 2 sub-compositions → only 1 `<script>` tag)
- [x] Verify external scripts already in `index.html` are not duplicated
- [x] Verify script order: external CDN scripts load before inline composition scripts
This commit is contained in:
Miguel Ángel
2026-03-31 22:31:56 +02:00
committed by GitHub
parent 6b1463fd5b
commit b43b6fb1dc
+29 -1
View File
@@ -490,6 +490,7 @@ function inlineSubCompositions(
const collectedStyles: string[] = [];
const collectedScripts: string[] = [];
const collectedExternalScriptSrcs: string[] = [];
for (const host of hosts) {
const srcPath = host.getAttribute("data-composition-src");
@@ -541,7 +542,15 @@ function inlineSubCompositions(
for (const scriptEl of contentDoc.querySelectorAll("script")) {
const src = (scriptEl.getAttribute("src") || "").trim();
if (src) continue;
if (src) {
// External CDN/remote script — collect for deduped injection into the
// parent document, mirroring the bundler's hoisting behavior.
if (!collectedExternalScriptSrcs.includes(src)) {
collectedExternalScriptSrcs.push(src);
}
scriptEl.remove();
continue;
}
const content = (scriptEl.textContent || "").trim();
if (content) {
const scriptMountCompId = compId || inferredCompId || "";
@@ -624,6 +633,25 @@ function inlineSubCompositions(
head.appendChild(styleEl);
}
// Inject external CDN scripts before inline scripts so plugins (e.g.
// TextPlugin, ScrollTrigger) are registered before composition code runs.
// Deduplicate against scripts already present in the document.
if (collectedExternalScriptSrcs.length && body) {
const existingScriptSrcs = new Set(
Array.from(document.querySelectorAll("script[src]")).map((el) =>
(el.getAttribute("src") || "").trim(),
),
);
for (const src of collectedExternalScriptSrcs) {
if (!existingScriptSrcs.has(src)) {
const scriptEl = document.createElement("script");
scriptEl.setAttribute("src", src);
body.appendChild(scriptEl);
existingScriptSrcs.add(src);
}
}
}
if (collectedScripts.length && body) {
const scriptEl = document.createElement("script");
scriptEl.textContent = collectedScripts.join("\n;\n");