Skip to content

Commit 375e298

Browse files
Merge pull request #1068 from func25/fix/subcomp-local-script-bundling
2 parents 68fce93 + 3bb0d1e commit 375e298

5 files changed

Lines changed: 204 additions & 38 deletions

File tree

packages/core/src/compiler/compositionScoping.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { parseHTML } from "linkedom";
3-
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
3+
import {
4+
scopeCssToComposition,
5+
wrapInlineScriptWithErrorBoundary,
6+
wrapScopedCompositionScript,
7+
} from "./compositionScoping";
48

59
describe("composition scoping", () => {
610
it("scopes regular selectors while preserving global at-rules", () => {
@@ -568,6 +572,26 @@ window.__afterTimeline = window.__timelines.scene;
568572
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
569573
});
570574

575+
it("wraps scoped composition script source as a string literal", () => {
576+
const wrapped = wrapScopedCompositionScript(
577+
'window.payload = "</script><script>window.pwned = true;</script>";',
578+
"scene",
579+
);
580+
581+
expect(wrapped).toContain('Function("document", "gsap", "window", "__hyperframes", ');
582+
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
583+
});
584+
585+
it("wraps unscoped composition script source as a string literal", () => {
586+
const wrapped = wrapInlineScriptWithErrorBoundary(
587+
'window.payload = "</script><script>window.pwned = true;</script>";',
588+
"[HyperFrames] composition script error:",
589+
);
590+
591+
expect(wrapped).toContain("Function(");
592+
expect(wrapped).toContain('\\"</script><script>window.pwned = true;</script>\\"');
593+
});
594+
571595
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
572596
const scoped = scopeCssToComposition(
573597
`#intro { background: #111; }

packages/core/src/compiler/compositionScoping.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export function wrapScopedCompositionScript(
216216
const authoredRootIdFormsLiteral = JSON.stringify(
217217
getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""),
218218
);
219+
const sourceLiteral = JSON.stringify(source);
219220
return `(function(){
220221
var __hfCompId = ${compositionIdLiteral};
221222
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
@@ -485,9 +486,8 @@ export function wrapScopedCompositionScript(
485486
});
486487
var __hfRun = function() {
487488
try {
488-
(function(document, gsap, window, __hyperframes) {
489-
${source}
490-
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
489+
var __hfScript = Function("document", "gsap", "window", "__hyperframes", ${sourceLiteral});
490+
__hfScript.call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
491491
} catch (_err) {
492492
console.error(__hfErrorLabel, __hfCompId, _err);
493493
}
@@ -496,3 +496,7 @@ ${source}
496496
__hfRun();
497497
})();`;
498498
}
499+
500+
export function wrapInlineScriptWithErrorBoundary(source: string, errorLabel: string): string {
501+
return `(function(){ try { Function(${JSON.stringify(source)}).call(window); } catch (_err) { console.error(${JSON.stringify(errorLabel)}, _err); } })();`;
502+
}

packages/core/src/compiler/htmlBundler.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,79 @@ describe("bundleToSingleHtml", () => {
250250
expect(hostEl?.hasAttribute("data-composition-src")).toBe(false);
251251
});
252252

253+
it("inlines local scripts referenced by sub-compositions into the bundle", async () => {
254+
const dir = makeTempProject({
255+
"index.html": `<!doctype html>
256+
<html><head>
257+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
258+
</head><body>
259+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
260+
<div id="scene-host"
261+
data-composition-id="scene"
262+
data-composition-src="compositions/scene.html"
263+
data-start="0" data-duration="5"></div>
264+
</div>
265+
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
266+
</body></html>`,
267+
"compositions/scene.html": `<template id="scene-template">
268+
<div data-composition-id="scene" data-width="1920" data-height="1080">
269+
<div id="scene-copy">Scene</div>
270+
<script src="vendor/effect-plugin.js"></script>
271+
<script src="assets/scene-runtime.js"></script>
272+
<script>
273+
window.__timelines = window.__timelines || {};
274+
window.__timelines["scene"] = gsap.timeline({ paused: true });
275+
</script>
276+
</div>
277+
</template>`,
278+
"vendor/effect-plugin.js": `window.PowerGlitch = { glitch(){ return { startGlitch(){}, stopGlitch(){} }; } };`,
279+
"assets/scene-runtime.js": `window.__HF_SHARED_TEST__ = "shared-runtime-loaded";`,
280+
});
281+
282+
const bundled = await bundleToSingleHtml(dir);
283+
284+
expect(bundled).toContain('__HF_SHARED_TEST__ = "shared-runtime-loaded"');
285+
expect(bundled).toContain("window.PowerGlitch = { glitch()");
286+
expect(bundled).not.toContain('src="assets/scene-runtime.js"');
287+
expect(bundled).not.toContain('src="vendor/effect-plugin.js"');
288+
});
289+
290+
it("preserves local sub-composition script order before inline scene scripts", async () => {
291+
const dir = makeTempProject({
292+
"index.html": `<!doctype html>
293+
<html><head>
294+
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
295+
</head><body>
296+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
297+
<div
298+
id="scene-host"
299+
data-composition-id="scene"
300+
data-composition-src="compositions/scene.html"
301+
data-start="0" data-duration="5"></div>
302+
</div>
303+
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
304+
</body></html>`,
305+
"compositions/scene.html": `<template id="scene-template">
306+
<div data-composition-id="scene" data-width="1920" data-height="1080">
307+
<script src="assets/component-runtime.js"></script>
308+
<script>
309+
window.__HF_COMPONENT_CALL__ = true;
310+
window.Component.mount("#scene-host");
311+
</script>
312+
</div>
313+
</template>`,
314+
"assets/component-runtime.js": `window.__HF_COMPONENT_DEF__ = true; window.Component = { mount(){ window.__HF_COMPONENT_MOUNTED__ = true; } };`,
315+
});
316+
317+
const bundled = await bundleToSingleHtml(dir);
318+
const componentIndex = bundled.indexOf("__HF_COMPONENT_DEF__");
319+
const sceneIndex = bundled.indexOf("__HF_COMPONENT_CALL__");
320+
321+
expect(componentIndex).toBeGreaterThan(-1);
322+
expect(sceneIndex).toBeGreaterThan(-1);
323+
expect(componentIndex).toBeLessThan(sceneIndex);
324+
});
325+
253326
it("does not duplicate CDN scripts already present in the main document", async () => {
254327
const dir = makeTempProject({
255328
"index.html": `<!doctype html>
@@ -685,7 +758,8 @@ describe("bundleToSingleHtml", () => {
685758
expect(bundled).toContain('[data-composition-id="scene"] .title { color: red; }');
686759
expect(bundled).toContain("new Proxy(window.document");
687760
expect(bundled).toContain("new Proxy(__hfBaseGsap");
688-
expect(bundled).toContain('tl.to(".title"');
761+
expect(bundled).toContain('Function("document", "gsap", "window", "__hyperframes",');
762+
expect(bundled).toContain("tl.to('.title'");
689763
});
690764

691765
it("isolates sibling instances of the same external sub-composition", async () => {

packages/core/src/compiler/htmlBundler.ts

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
stripEmbeddedRuntimeScripts,
99
} from "./htmlDocument";
1010
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
11-
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
11+
import {
12+
scopeCssToComposition,
13+
wrapInlineScriptWithErrorBoundary,
14+
wrapScopedCompositionScript,
15+
} from "./compositionScoping";
1216
import { validateHyperframeHtmlContract } from "./staticGuard";
1317
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
1418
import { readDeclaredDefaults } from "../runtime/getVariables";
@@ -718,12 +722,34 @@ export async function bundleToSingleHtml(
718722
},
719723
});
720724
const compStyleChunks: string[] = [...subCompResult.styles];
721-
const compScriptChunks: string[] = [...subCompResult.scripts];
722-
const compExternalScriptSrcs: string[] = [...subCompResult.externalScriptSrcs];
725+
const compScriptChunks: string[] = [];
723726
const compExternalLinks = [...subCompResult.externalLinks];
724727
const compVariablesByComp: Record<string, Record<string, unknown>> = {
725728
...subCompResult.variablesByComp,
726729
};
730+
const seenCompScriptSrcs = new Set<string>();
731+
for (const scriptItem of subCompResult.scriptItems) {
732+
if (scriptItem.kind === "inline") {
733+
compScriptChunks.push(scriptItem.content);
734+
continue;
735+
}
736+
const extSrc = scriptItem.src;
737+
if (seenCompScriptSrcs.has(extSrc)) continue;
738+
seenCompScriptSrcs.add(extSrc);
739+
if (isRelativeUrl(extSrc)) {
740+
const jsPath = safePath(projectDir, extSrc);
741+
const js = jsPath ? safeReadFile(jsPath) : null;
742+
if (js != null) {
743+
compScriptChunks.push(js);
744+
continue;
745+
}
746+
}
747+
if (!document.querySelector(`script[src="${extSrc}"]`)) {
748+
const extScript = document.createElement("script");
749+
extScript.setAttribute("src", extSrc);
750+
document.body.appendChild(extScript);
751+
}
752+
}
727753

728754
// Inline template compositions: inject <template id="X-template"> content into
729755
// matching empty host elements with data-composition-id="X" (no data-composition-src)
@@ -773,8 +799,23 @@ export async function bundleToSingleHtml(
773799
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
774800
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
775801
if (externalSrc) {
776-
if (!compExternalScriptSrcs.includes(externalSrc)) {
777-
compExternalScriptSrcs.push(externalSrc);
802+
if (!seenCompScriptSrcs.has(externalSrc)) {
803+
seenCompScriptSrcs.add(externalSrc);
804+
if (isRelativeUrl(externalSrc)) {
805+
const jsPath = safePath(projectDir, externalSrc);
806+
const js = jsPath ? safeReadFile(jsPath) : null;
807+
if (js != null) {
808+
compScriptChunks.push(js);
809+
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
810+
const extScript = document.createElement("script");
811+
extScript.setAttribute("src", externalSrc);
812+
document.body.appendChild(extScript);
813+
}
814+
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
815+
const extScript = document.createElement("script");
816+
extScript.setAttribute("src", externalSrc);
817+
document.body.appendChild(extScript);
818+
}
778819
}
779820
} else {
780821
compScriptChunks.push(
@@ -787,7 +828,10 @@ export async function bundleToSingleHtml(
787828
runtimeCompId || compId,
788829
authoredRootId,
789830
)
790-
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
831+
: wrapInlineScriptWithErrorBoundary(
832+
scriptEl.textContent || "",
833+
"[HyperFrames] composition script error:",
834+
),
791835
);
792836
}
793837
scriptEl.remove();
@@ -810,8 +854,23 @@ export async function bundleToSingleHtml(
810854
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
811855
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
812856
if (externalSrc) {
813-
if (!compExternalScriptSrcs.includes(externalSrc)) {
814-
compExternalScriptSrcs.push(externalSrc);
857+
if (!seenCompScriptSrcs.has(externalSrc)) {
858+
seenCompScriptSrcs.add(externalSrc);
859+
if (isRelativeUrl(externalSrc)) {
860+
const jsPath = safePath(projectDir, externalSrc);
861+
const js = jsPath ? safeReadFile(jsPath) : null;
862+
if (js != null) {
863+
compScriptChunks.push(js);
864+
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
865+
const extScript = document.createElement("script");
866+
extScript.setAttribute("src", externalSrc);
867+
document.body.appendChild(extScript);
868+
}
869+
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
870+
const extScript = document.createElement("script");
871+
extScript.setAttribute("src", externalSrc);
872+
document.body.appendChild(extScript);
873+
}
815874
}
816875
} else {
817876
compScriptChunks.push(
@@ -823,7 +882,10 @@ export async function bundleToSingleHtml(
823882
runtimeScope,
824883
runtimeCompId || compId,
825884
)
826-
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
885+
: wrapInlineScriptWithErrorBoundary(
886+
scriptEl.textContent || "",
887+
"[HyperFrames] composition script error:",
888+
),
827889
);
828890
}
829891
scriptEl.remove();
@@ -839,14 +901,6 @@ export async function bundleToSingleHtml(
839901

840902
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
841903
// that aren't already present in the main document.
842-
for (const extSrc of compExternalScriptSrcs) {
843-
if (!document.querySelector(`script[src="${extSrc}"]`)) {
844-
const extScript = document.createElement("script");
845-
extScript.setAttribute("src", extSrc);
846-
document.body.appendChild(extScript);
847-
}
848-
}
849-
850904
for (const link of compExternalLinks) {
851905
const escapedHref = link.href.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
852906
if (!document.querySelector(`link[href="${escapedHref}"]`)) {

packages/core/src/compiler/inlineSubCompositions.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
rewriteCssAssetUrls,
1414
rewriteInlineStyleAssetUrls,
1515
} from "./rewriteSubCompPaths";
16-
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
16+
import {
17+
scopeCssToComposition,
18+
wrapInlineScriptWithErrorBoundary,
19+
wrapScopedCompositionScript,
20+
} from "./compositionScoping";
1721

1822
// ---------------------------------------------------------------------------
1923
// Public interface
@@ -106,6 +110,7 @@ export interface InlineSubCompositionsResult {
106110
styles: string[];
107111
scripts: string[];
108112
externalScriptSrcs: string[];
113+
scriptItems: Array<{ kind: "inline"; content: string } | { kind: "external"; src: string }>;
109114
externalLinks: { href: string; rel: string; crossorigin?: string }[];
110115
variablesByComp: Record<string, Record<string, unknown>>;
111116
}
@@ -160,6 +165,7 @@ export function inlineSubCompositions(
160165
const styles: string[] = [];
161166
const scripts: string[] = [];
162167
const externalScriptSrcs: string[] = [];
168+
const scriptItems: InlineSubCompositionsResult["scriptItems"] = [];
163169
const externalLinks: { href: string; rel: string; crossorigin?: string }[] = [];
164170
const seenLinkHrefs = new Set<string>();
165171
const variablesByComp: Record<string, Record<string, unknown>> = {};
@@ -232,8 +238,11 @@ export function inlineSubCompositions(
232238
}
233239
for (const s of [...compDoc.head.querySelectorAll("script")]) {
234240
const externalSrc = (s.getAttribute("src") || "").trim();
235-
if (externalSrc && !externalScriptSrcs.includes(externalSrc)) {
236-
externalScriptSrcs.push(externalSrc);
241+
if (externalSrc) {
242+
if (!externalScriptSrcs.includes(externalSrc)) {
243+
externalScriptSrcs.push(externalSrc);
244+
}
245+
scriptItems.push({ kind: "external", src: externalSrc });
237246
}
238247
}
239248
for (const link of [
@@ -271,19 +280,20 @@ export function inlineSubCompositions(
271280
if (!externalScriptSrcs.includes(externalSrc)) {
272281
externalScriptSrcs.push(externalSrc);
273282
}
283+
scriptItems.push({ kind: "external", src: externalSrc });
274284
} else {
275-
scripts.push(
276-
scopeCompId
277-
? wrapScopedCompositionScript(
278-
s.textContent || "",
279-
scopeCompId,
280-
scriptErrorLabel,
281-
runtimeScope || undefined,
282-
runtimeCompId || scopeCompId,
283-
authoredRootId,
284-
)
285-
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error(${JSON.stringify(scriptErrorLabel)}, _err); } })();`,
286-
);
285+
const wrappedScript = scopeCompId
286+
? wrapScopedCompositionScript(
287+
s.textContent || "",
288+
scopeCompId,
289+
scriptErrorLabel,
290+
runtimeScope || undefined,
291+
runtimeCompId || scopeCompId,
292+
authoredRootId,
293+
)
294+
: wrapInlineScriptWithErrorBoundary(s.textContent || "", scriptErrorLabel);
295+
scripts.push(wrappedScript);
296+
scriptItems.push({ kind: "inline", content: wrappedScript });
287297
}
288298
s.remove();
289299
}
@@ -359,5 +369,5 @@ export function inlineSubCompositions(
359369
hostEl.removeAttribute("data-composition-src");
360370
}
361371

362-
return { styles, scripts, externalScriptSrcs, externalLinks, variablesByComp };
372+
return { styles, scripts, externalScriptSrcs, scriptItems, externalLinks, variablesByComp };
363373
}

0 commit comments

Comments
 (0)