Skip to content

Commit bc80685

Browse files
committed
fix(core): bundled local scripts keep their own place, defer and async
The bundler joined every local <script src> into one inline script at the first one's position. That dropped defer and async (a deferred main.js ran before a deferred CDN gsap), moved later files ahead of CDN scripts between them, and let one file's error stop the rest. Each local script is now inlined where it stands with its attributes; a deferred or async one keeps its tag with its file as a data: URL. Inline-script merging also no longer folds in nomodule scripts, which dropped the attribute.
1 parent cad3db4 commit bc80685

3 files changed

Lines changed: 100 additions & 23 deletions

File tree

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2514,6 +2514,97 @@ describe("bundleToSingleHtml script order", () => {
25142514
}
25152515
});
25162516

2517+
const LOCAL_ORDER_GSAP = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js";
2518+
async function bundledBody(scripts: string, files: Record<string, string>) {
2519+
const dir = makeTempProject({
2520+
"index.html": `<!doctype html>
2521+
<html><body>
2522+
<div data-composition-id="root" data-width="320" data-height="180"></div>
2523+
${scripts}
2524+
</body></html>`,
2525+
...files,
2526+
});
2527+
try {
2528+
const { document } = parseHTML(await bundleToSingleHtml(dir));
2529+
return [...document.querySelectorAll("body script")];
2530+
} finally {
2531+
rmSync(dir, { recursive: true, force: true });
2532+
}
2533+
}
2534+
const dataUrlSource = (el: Element | undefined) =>
2535+
decodeURIComponent(
2536+
(el?.getAttribute("src") ?? "").replace(/^data:text\/javascript;charset=utf-8,/, ""),
2537+
);
2538+
2539+
it("keeps each local script in its own place around a CDN script", async () => {
2540+
const scripts = await bundledBody(
2541+
`<script src="setup.js"></script>
2542+
<script src="${LOCAL_ORDER_GSAP}"></script>
2543+
<script src="main.js"></script>`,
2544+
{ "setup.js": "window.SETUP_RAN = 1;", "main.js": "window.MAIN_RAN = gsap.version;" },
2545+
);
2546+
const setupAt = scripts.findIndex((el) => el.textContent?.includes("SETUP_RAN"));
2547+
const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP);
2548+
const mainAt = scripts.findIndex((el) => el.textContent?.includes("MAIN_RAN"));
2549+
expect(setupAt).toBeGreaterThan(-1);
2550+
expect(setupAt).toBeLessThan(gsapAt);
2551+
expect(gsapAt).toBeLessThan(mainAt);
2552+
});
2553+
2554+
it.each(["defer", "async"])(
2555+
"keeps a local %s script's own timing, with its file as the source",
2556+
async (when) => {
2557+
const scripts = await bundledBody(
2558+
`<script defer src="${LOCAL_ORDER_GSAP}"></script>
2559+
<script ${when} src="main.js"></script>`,
2560+
{ "main.js": "window.MAIN_RAN = gsap.version;" },
2561+
);
2562+
const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === LOCAL_ORDER_GSAP);
2563+
const main = scripts[gsapAt + 1];
2564+
expect(main?.hasAttribute(when)).toBe(true);
2565+
expect(main?.getAttribute("src")).toMatch(/^data:text\/javascript;charset=utf-8,/);
2566+
expect(dataUrlSource(main)).toBe("window.MAIN_RAN = gsap.version;");
2567+
expect(
2568+
scripts.some((el) => !el.hasAttribute("src") && el.textContent?.includes("MAIN_RAN")),
2569+
).toBe(false);
2570+
},
2571+
);
2572+
2573+
it("keeps a throw in one local script from stopping the next", async () => {
2574+
const scripts = await bundledBody(
2575+
`<script src="a.js"></script>\n <script src="${LOCAL_ORDER_GSAP}"></script>\n <script src="b.js"></script>`,
2576+
{
2577+
"a.js": "throw new Error('a');",
2578+
"b.js": "window.B_RAN = 1;",
2579+
},
2580+
);
2581+
const holders = scripts.filter((el) => /throw new Error|B_RAN/.test(el.textContent ?? ""));
2582+
expect(holders).toHaveLength(2);
2583+
});
2584+
2585+
it.each([
2586+
["a local file", `<script nomodule src="legacy.js"></script>`],
2587+
["an inline script", `<script nomodule>window.LEGACY_RAN = 1;</script>`],
2588+
])("keeps nomodule on %s, so modern browsers still skip it", async (_, legacy) => {
2589+
const scripts = await bundledBody(
2590+
`<script>window.FIRST = 1;</script>\n ${legacy}\n <script>window.LAST = 1;</script>`,
2591+
{
2592+
"legacy.js": "window.LEGACY_RAN = 1;",
2593+
},
2594+
);
2595+
const holder = scripts.find((el) => el.textContent?.includes("LEGACY_RAN"));
2596+
expect(holder?.hasAttribute("nomodule")).toBe(true);
2597+
expect(holder?.textContent).not.toMatch(/FIRST|LAST/);
2598+
});
2599+
2600+
it("keeps a local script's non-JavaScript type, so it is not run as JavaScript", async () => {
2601+
const scripts = await bundledBody(`<script type="text/babel" src="app.jsx"></script>`, {
2602+
"app.jsx": "const App = () => <div />;",
2603+
});
2604+
const holder = scripts.find((el) => el.textContent?.includes("const App"));
2605+
expect(holder?.getAttribute("type")).toBe("text/babel");
2606+
});
2607+
25172608
it("still merges adjacent inline scripts into one at the end of the body", async () => {
25182609
const dir = makeTempProject({
25192610
"index.html": `<!doctype html>

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

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,9 +1226,6 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
12261226
templateEl.remove();
12271227
}
12281228

1229-
// Inline local JS
1230-
const localJsChunks: string[] = [];
1231-
let jsAnchorPlaced = false;
12321229
for (const el of [...document.querySelectorAll("script[src]")]) {
12331230
const src = el.getAttribute("src");
12341231
if (!src || !isRelativeUrl(src)) continue;
@@ -1240,27 +1237,15 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
12401237
const jsPath = resolveEntryPath(src);
12411238
const js = jsPath ? safeReadFile(jsPath) : null;
12421239
if (js == null) continue;
1243-
localJsChunks.push(js);
1244-
if (!jsAnchorPlaced) {
1245-
const anchor = document.createElement("script");
1246-
anchor.setAttribute("data-hf-bundled-local-js", "1");
1247-
el.replaceWith(anchor);
1248-
jsAnchorPlaced = true;
1249-
} else {
1250-
el.remove();
1251-
}
1252-
}
1253-
if (localJsChunks.length > 0) {
1254-
const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]');
1255-
const joinedJs = joinJsChunks(localJsChunks);
1256-
if (anchor) {
1257-
anchor.removeAttribute("data-hf-bundled-local-js");
1258-
anchor.textContent = joinedJs;
1259-
} else {
1260-
const script = document.createElement("script");
1261-
script.textContent = joinedJs;
1262-
document.body.appendChild(script);
1240+
if (el.hasAttribute("defer") || el.hasAttribute("async")) {
1241+
el.setAttribute("src", `data:text/javascript;charset=utf-8,${encodeURIComponent(js)}`);
1242+
continue;
12631243
}
1244+
const inline = document.createElement("script");
1245+
for (const { name, value } of [...el.attributes])
1246+
if (name !== "src") inline.setAttribute(name, value);
1247+
inline.textContent = js;
1248+
el.replaceWith(inline);
12641249
}
12651250

12661251
for (const link of compExternalLinks) ensureExternalLinkTag(document, link);

‎packages/core/src/compiler/scriptRuns.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface InlineScriptRun {
55
}
66

77
function isClassicInline(el: Element): boolean {
8+
if (el.hasAttribute("nomodule")) return false;
89
const type = (el.getAttribute("type") || "").trim().toLowerCase();
910
return !type || type === "text/javascript" || type === "application/javascript";
1011
}

0 commit comments

Comments
 (0)