From 4b868525e8ea6d617e368b5f989c2dcbb741ef3e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 05:14:44 -0700 Subject: [PATCH 01/10] fix(core): bundled local scripts keep their own place, defer and async The bundler joined every local + + `, + { "setup.js": "window.SETUP_RAN = 1;", "main.js": "window.MAIN_RAN = gsap.version;" }, + ); + const setupAt = scripts.findIndex((el) => el.textContent?.includes("SETUP_RAN")); + const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP); + const mainAt = scripts.findIndex((el) => el.textContent?.includes("MAIN_RAN")); + expect(setupAt).toBeGreaterThan(-1); + expect(setupAt).toBeLessThan(gsapAt); + expect(gsapAt).toBeLessThan(mainAt); + }); + + it.each(["defer", "async"])( + "keeps a local %s script's own timing, with its file as the source", + async (when) => { + const scripts = await bundledBody( + ` + `, + { "main.js": "window.MAIN_RAN = gsap.version;" }, + ); + const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP); + const main = scripts[gsapAt + 1]; + expect(main?.hasAttribute(when)).toBe(true); + expect(main?.getAttribute("src")).toMatch(/^data:text\/javascript;charset=utf-8,/); + expect(dataUrlSource(main)).toBe("window.MAIN_RAN = gsap.version;"); + expect( + scripts.some((el) => !el.hasAttribute("src") && el.textContent?.includes("MAIN_RAN")), + ).toBe(false); + }, + ); + + it("keeps a throw in one local script from stopping the next", async () => { + const scripts = await bundledBody( + `\n \n `, + { + "a.js": "throw new Error('a');", + "b.js": "window.B_RAN = 1;", + }, + ); + const holders = scripts.filter((el) => /throw new Error|B_RAN/.test(el.textContent ?? "")); + expect(holders).toHaveLength(2); + }); + + it.each([ + ["a local file", ``], + ["an inline script", ``], + ])("keeps nomodule on %s, so modern browsers still skip it", async (_, legacy) => { + const scripts = await bundledBody( + `\n ${legacy}\n `, + { + "legacy.js": "window.LEGACY_RAN = 1;", + }, + ); + const holder = scripts.find((el) => el.textContent?.includes("LEGACY_RAN")); + expect(holder?.hasAttribute("nomodule")).toBe(true); + expect(holder?.textContent).not.toMatch(/FIRST|LAST/); + }); + + it("keeps a local script's non-JavaScript type, so it is not run as JavaScript", async () => { + const scripts = await bundledBody(``, { + "app.jsx": "const App = () =>
;", + }); + const holder = scripts.find((el) => el.textContent?.includes("const App")); + expect(holder?.getAttribute("type")).toBe("text/babel"); + }); + it("still merges adjacent inline scripts into one at the end of the body", async () => { const dir = makeTempProject({ "index.html": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 399f0c3c9e8..9fbe82c600b 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1226,9 +1226,6 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi templateEl.remove(); } - // Inline local JS - const localJsChunks: string[] = []; - let jsAnchorPlaced = false; for (const el of [...document.querySelectorAll("script[src]")]) { const src = el.getAttribute("src"); if (!src || !isRelativeUrl(src)) continue; @@ -1240,27 +1237,15 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi const jsPath = resolveEntryPath(src); const js = jsPath ? safeReadFile(jsPath) : null; if (js == null) continue; - localJsChunks.push(js); - if (!jsAnchorPlaced) { - const anchor = document.createElement("script"); - anchor.setAttribute("data-hf-bundled-local-js", "1"); - el.replaceWith(anchor); - jsAnchorPlaced = true; - } else { - el.remove(); - } - } - if (localJsChunks.length > 0) { - const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]'); - const joinedJs = joinJsChunks(localJsChunks); - if (anchor) { - anchor.removeAttribute("data-hf-bundled-local-js"); - anchor.textContent = joinedJs; - } else { - const script = document.createElement("script"); - script.textContent = joinedJs; - document.body.appendChild(script); + if (el.hasAttribute("defer") || el.hasAttribute("async")) { + el.setAttribute("src", `data:text/javascript;charset=utf-8,${encodeURIComponent(js)}`); + continue; } + const inline = document.createElement("script"); + for (const { name, value } of [...el.attributes]) + if (name !== "src") inline.setAttribute(name, value); + inline.textContent = js; + el.replaceWith(inline); } for (const link of compExternalLinks) ensureExternalLinkTag(document, link); diff --git a/packages/core/src/compiler/scriptRuns.ts b/packages/core/src/compiler/scriptRuns.ts index 29801174e61..97f5ed54550 100644 --- a/packages/core/src/compiler/scriptRuns.ts +++ b/packages/core/src/compiler/scriptRuns.ts @@ -5,6 +5,7 @@ export interface InlineScriptRun { } function isClassicInline(el: Element): boolean { + if (el.hasAttribute("nomodule")) return false; const type = (el.getAttribute("type") || "").trim().toLowerCase(); return !type || type === "text/javascript" || type === "application/javascript"; } From 77c38f3df4d0ebe77e21a43e51d6163bfdbc8d67 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 08:52:24 -0700 Subject: [PATCH 02/10] fix: preview scripts run after the runtime when the bundler fails, and inlined files are escaped - When the bundler returns nothing or throws, the preview places the runtime where the bundled page does (end of head) instead of after the composition's scripts or not at all; core's placement moves to insertRuntimeTag. - Every HTML-spec JavaScript MIME type counts as a classic script for merging and the web-font gate. - Inlined local files and merged runs are escaped with escapeInlineScriptSource. --- .../core/src/compiler/htmlBundler.test.ts | 32 +++++++++++-------- packages/core/src/compiler/htmlBundler.ts | 21 +++--------- packages/core/src/compiler/htmlDocument.ts | 18 ++++++++++- packages/core/src/compiler/index.ts | 1 + packages/core/src/compiler/scriptRuns.test.ts | 17 ++++++++++ packages/core/src/compiler/scriptRuns.ts | 24 ++++++++++++-- .../studio-server/src/routes/preview.test.ts | 27 ++++++++++++++++ packages/studio-server/src/routes/preview.ts | 28 ++++++++-------- 8 files changed, 120 insertions(+), 48 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index c78fa045b3a..a89302d106b 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -2557,30 +2557,34 @@ describe("bundleToSingleHtml script order", () => { const scripts = await bundledBody( ` `, - { "main.js": "window.MAIN_RAN = gsap.version;" }, + { "main.js": "window.MAIN_RAN = gsap.version; // 100% #1" }, ); const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP); const main = scripts[gsapAt + 1]; expect(main?.hasAttribute(when)).toBe(true); - expect(main?.getAttribute("src")).toMatch(/^data:text\/javascript;charset=utf-8,/); - expect(dataUrlSource(main)).toBe("window.MAIN_RAN = gsap.version;"); + expect(main?.getAttribute("src")).toMatch(/^data:text\/javascript;charset=utf-8,[^#]*$/); + expect(dataUrlSource(main)).toBe("window.MAIN_RAN = gsap.version; // 100% #1"); expect( scripts.some((el) => !el.hasAttribute("src") && el.textContent?.includes("MAIN_RAN")), ).toBe(false); }, ); - it("keeps a throw in one local script from stopping the next", async () => { - const scripts = await bundledBody( - `\n \n `, - { - "a.js": "throw new Error('a');", - "b.js": "window.B_RAN = 1;", - }, - ); - const holders = scripts.filter((el) => /throw new Error|B_RAN/.test(el.textContent ?? "")); - expect(holders).toHaveLength(2); - }); + it.each(["", ' type="text/babel"'])( + "escapes a local file's script-closing text, so the page keeps its structure (%s)", + async (type) => { + const scripts = await bundledBody( + `

ok

`, + { + "main.js": `window.CLOSE = "

x

"; window.OPEN = "