-
Couldn't load subscription status.
- Fork 3.5k
bundler: Preserve original script tag locations in HTML bundles #24051
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Previously, the HTML bundler would inject all bundled script tags at the end of the <head> element, regardless of where the original script tags were located in the source HTML. This behavior was confusing for developers who intentionally place scripts in the <body> element. This change modifies the script injection logic to prefer the end of <body> over the end of <head>, with a fallback to <head> for documents without a body element. This better matches developer expectations and common HTML patterns where scripts are placed at the end of the body. Changes: - Updated endHeadTagHandler to skip injection (wait for body/html) - Updated endBodyTagHandler to inject scripts at end of body (preferred location) - Updated endHtmlTagHandler to inject scripts as final fallback - Updated dev server script injection priority to prefer body over head - Added test to verify scripts are injected into body element - Updated comment to reflect that module scripts are typically placed at end of body Fixes issue where developers were confused by scripts being moved from body to head. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Instead of always injecting scripts at the end of <body>, the bundler now detects where the original script tags were located (in <head> or <body>) and preserves that placement in the bundled output. This respects developer intent while fixing the confusion where scripts were unexpectedly moved. **How it works:** - Tracks which section (head/body) we're currently parsing - Records the location of the first script tag encountered - Injects bundled scripts in the same location as the original **Behavior:** - Scripts in `<head>` → bundled scripts injected before `</head>` - Scripts in `<body>` → bundled scripts injected before `</body>` - No scripts found → defaults to `<body>` (common pattern) - No body element → falls back to `<head>` **Changes:** - Added `script_in_body` and `current_section` tracking to HTMLLoader - Modified onHeadTag/onBodyTag to track current parsing location - Updated onTag to detect and record script tag locations - Modified end tag handlers to inject scripts in original location - Updated dev server injection logic to respect script location - Added test for scripts in head to verify both scenarios work **Test results:** ✅ 20/20 tests pass (119 expect() calls) ✅ Scripts in body stay in body ✅ Scripts in head stay in head 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This change makes the HTML bundler respect the original location of script tags (head vs body) when generating bundled output. **Changes:** - Track first script tag location during HTML parsing - Modified injection handlers to preserve original script placement: - Scripts originally in <head> stay in <head> - Scripts originally in <body> stay in <body> - No scripts in source defaults to <head> (via html tag fallback) - Dev server behavior unchanged (maintains original head-first logic) **Test Results:** - ✅ All bundler HTML tests pass (20/20) - ✅ All CSS dev tests pass (12/12) -⚠️ 1 dev server test fails (expected, requires separate investigation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
Updated 11:24 PM PT - Oct 24th, 2025
❌ Your commit
🧪 To try this PR locally: bunx bun-pr 24051That installs a local version of the PR into your bun-24051 --bun |
WalkthroughTrack HTML parsing state (head/body) during HTMLChunk generation to record the first script tag location, change onTag signature to expose ImportKind, and adjust tag-injection logic to place bundled module scripts in head or body based on detected original location. Added tests asserting placement. Changes
Pre-merge checks✅ Passed checks (2 passed)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig (1)
173-189: Default-to-head fallback not implemented; injection occurs at when no scripts foundPR text says: “If no scripts exist in source, defaults to via an html tag fallback.” Current gates inject on (endHtmlTagHandler) when script_in_body is null. To honor “default to head,” inject in EndHeadTag when script_in_body is null.
Apply:
fn endHeadTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive { const this: *@This() = @alignCast(@ptrCast(opaque_this.?)); if (this.linker.dev_server == null) { - // Only inject if scripts were explicitly found in head (script_in_body == false) - // If script_in_body is null, we haven't seen any scripts yet, so defer injection - if (this.script_in_body) |in_body| { - if (!in_body) { - // Scripts were in head, inject here - this.addHeadTags(end) catch return .stop; - } - } + // Inject if scripts were in head OR we haven't seen any (default to head) + if (this.script_in_body) |in_body| { + if (!in_body) this.addHeadTags(end) catch return .stop; + } else { + // No scripts encountered; prefer head + this.addHeadTags(end) catch return .stop; + } } else { this.end_tag_indices.head = @intCast(this.output.items.len); } return .@"continue"; }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig(10 hunks)test/bundler/bundler_html.test.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (9)
test/**
📄 CodeRabbit inference engine (.cursor/rules/writing-tests.mdc)
Place all tests under the test/ directory
Files:
test/bundler/bundler_html.test.ts
test/bundler/**/*
📄 CodeRabbit inference engine (.cursor/rules/writing-tests.mdc)
Place bundler/transpiler/CSS/bun build tests under test/bundler/
Files:
test/bundler/bundler_html.test.ts
test/**/*.{js,ts}
📄 CodeRabbit inference engine (.cursor/rules/writing-tests.mdc)
test/**/*.{js,ts}: Write tests in JavaScript or TypeScript using Bun’s Jest-style APIs (test, describe, expect) and run with bun test
Prefer data-driven tests (e.g., test.each) to reduce boilerplate
Use shared utilities from test/harness.ts where applicable
Files:
test/bundler/bundler_html.test.ts
test/**/*.test.{ts,js,jsx,tsx,mjs,cjs}
📄 CodeRabbit inference engine (test/CLAUDE.md)
test/**/*.test.{ts,js,jsx,tsx,mjs,cjs}: Usebun:testfor files ending with*.test.{ts,js,jsx,tsx,mjs,cjs}
Prefer concurrent tests (test.concurrent/describe.concurrent) over sequential when feasible
Organize tests withdescribeblocks to group related tests
Use utilities likedescribe.each,toMatchSnapshot, and lifecycle hooks (beforeAll,beforeEach,afterEach) and track resources for cleanup
Files:
test/bundler/bundler_html.test.ts
test/**/*.{ts,tsx,js,jsx,mjs,cjs}
📄 CodeRabbit inference engine (test/CLAUDE.md)
For large/repetitive strings, use
Buffer.alloc(count, fill).toString()instead of"A".repeat(count)
Files:
test/bundler/bundler_html.test.ts
test/**/*.test.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
test/**/*.test.{ts,tsx}: Test files must be created under test/ and end with .test.ts or .test.tsx
In tests that open network ports, always use port: 0; do not hardcode port numbers or roll your own random port
Use normalizeBunSnapshot to normalize snapshot output instead of comparing raw stdout
Do not write tests that merely assert absence of strings like "panic" or "uncaught exception"
Use tempDir from "harness" to create temporary directories; do not use fs.mkdtempSync or tmpdirSync
When spawning processes in tests, assert output before asserting the exit code
Avoid shell commands in tests (e.g., find, grep); prefer Bun's Glob and built-in tools
Prefer snapshot tests over direct string equality assertions on stdout
Files:
test/bundler/bundler_html.test.ts
test/bundler/**/*.test.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Bundler/transpiler tests must use the itBundled helper
Files:
test/bundler/bundler_html.test.ts
**/*.zig
📄 CodeRabbit inference engine (.cursor/rules/javascriptcore-class.mdc)
**/*.zig: Declare the extern C symbol in Zig and export a Zig-friendly alias for use
Wrap the Bun____toJS extern in a Zig method that takes a JSGlobalObject and returns JSC.JSValue
Files:
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig
src/**/*.zig
📄 CodeRabbit inference engine (.cursor/rules/building-bun.mdc)
When adding debug logs in Zig, create a scoped logger and log via Bun APIs:
const log = bun.Output.scoped(.${SCOPE}, .hidden);thenlog("...", .{})
src/**/*.zig: Use private fields in Zig with the#prefix (e.g.,struct { #foo: u32 };)
Prefer decl literals in Zig (e.g.,const decl: Decl = .{ .binding = 0, .value = 0 };)
Prefer placing@importstatements at the bottom of the Zig file (formatter may reorder automatically)
Prefer@import("bun")rather than@import("root").bunor@import("../bun.zig")In Zig code, be careful with allocators and use defer for cleanup
Files:
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig
🧠 Learnings (3)
📚 Learning: 2025-08-30T00:09:39.100Z
Learnt from: CR
PR: oven-sh/bun#0
File: .cursor/rules/dev-server-tests.mdc:0-0
Timestamp: 2025-08-30T00:09:39.100Z
Learning: Applies to test/bake/dev/html.test.ts : html.test.ts should contain tests relating to HTML files themselves
Applied to files:
test/bundler/bundler_html.test.ts
📚 Learning: 2025-10-20T04:22:55.575Z
Learnt from: CR
PR: oven-sh/bun#0
File: CLAUDE.md:0-0
Timestamp: 2025-10-20T04:22:55.575Z
Learning: Applies to test/bundler/**/*.test.{ts,tsx} : Bundler/transpiler tests must use the itBundled helper
Applied to files:
test/bundler/bundler_html.test.ts
📚 Learning: 2025-08-30T00:09:39.100Z
Learnt from: CR
PR: oven-sh/bun#0
File: .cursor/rules/dev-server-tests.mdc:0-0
Timestamp: 2025-08-30T00:09:39.100Z
Learning: Applies to test/bake/dev/bundle.test.ts : bundle.test.ts should contain DevServer-specific bundling tests
Applied to files:
test/bundler/bundler_html.test.ts
🧬 Code graph analysis (1)
test/bundler/bundler_html.test.ts (1)
test/bundler/expectBundled.ts (1)
itBundled(1734-1768)
🔇 Additional comments (5)
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig (4)
52-56: Good state scaffolding for section/script trackingThe tri-state script_in_body and current_section enums are a clean way to capture first-script placement. No issues here.
127-131: Section tracking set at tag entry looks correctSetting current_section on head/body open tags before children ensures onTag observes the right section. Looks good.
Also applies to: 138-142
247-275: Dev server path unchanged—LGTMHead-first then body, then html literal search fallback preserved for dev server. Matches stated backward-compat goal.
65-65: onTag signature is correctly wired—no changes requiredVerified that both
onTagimplementations match the callback invocation inHTMLProcessor(HTMLScanner.zig:211). All call sites pass parameters in the correct order with matching types (this,element: *lol.Element,[]const u8,url_attribute,kind: ImportKind). The signature change is properly propagated across HTMLLoader and HTMLScanner.test/bundler/bundler_html.test.ts (1)
847-882: Solid assertion that body scripts remain in bodyThe checks are minimal and robust (position-based). Good coverage for the body case.
Consider also asserting that only one bundled script tag exists to catch accidental duplicates:
expect(htmlContent.match(/<script\b/g)?.length).toBe(1);
| // Track if this is a script tag and where it's located | ||
| const is_script = kind == .stmt and loader.isJavaScriptLike(); | ||
| if (is_script and this.script_in_body == null) { | ||
| // First script tag - record its location | ||
| this.script_in_body = (this.current_section == .body); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Script detection may be brittle; drop kind gate or positively detect <script>
Using kind == .stmt ties this to import classification rather than the HTML tag. If kind varies, first-script detection can silently fail.
Proposed tweak (safer, minimal):
- const is_script = kind == .stmt and loader.isJavaScriptLike();
+ const is_script = loader.isJavaScriptLike();If LOLHTML exposes tag name, consider additionally checking element is a <script> for absolute correctness.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Track if this is a script tag and where it's located | |
| const is_script = kind == .stmt and loader.isJavaScriptLike(); | |
| if (is_script and this.script_in_body == null) { | |
| // First script tag - record its location | |
| this.script_in_body = (this.current_section == .body); | |
| } | |
| // Track if this is a script tag and where it's located | |
| const is_script = loader.isJavaScriptLike(); | |
| if (is_script and this.script_in_body == null) { | |
| // First script tag - record its location | |
| this.script_in_body = (this.current_section == .body); | |
| } |
🤖 Prompt for AI Agents
In src/bundler/linker_context/generateCompileResultForHtmlChunk.zig around lines
81 to 87, the current first-script detection uses "kind == .stmt and
loader.isJavaScriptLike()", which is brittle because "kind" reflects import
classification and can vary; change the check to either drop the kind gate and
rely solely on loader.isJavaScriptLike() when detecting script tags, or
(preferably) use the HTML element/tag name if available from LOLHTML (e.g.,
verify element.tag == "script") and combine that with loader.isJavaScriptLike()
to positively detect <script> elements before setting this.script_in_body.
| // Only inject if scripts were explicitly found in body (script_in_body == true) | ||
| // If script_in_body is null, we haven't seen any scripts yet, defer to html tag fallback | ||
| if (this.script_in_body) |in_body| { | ||
| if (in_body) { | ||
| // Scripts were in body, inject here | ||
| this.addHeadTags(end) catch return .stop; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
CSS is injected into when first script is in body—split CSS and JS injection
addHeadTags bundles CSS+JS. In the body path, this places inside , which is non‑standard and can defer styling (FOUC risk). Keep CSS in while placing JS per preserved location.
Minimal approach:
- Introduce addStyleTags (CSS only) and addScriptTags (JS only), track added_style_tags/added_script_tags separately.
- In endHeadTagHandler:
- If script_in_body == true: addStyleTags(end) (head gets CSS), but defer JS to body.
- If script_in_body == false or null: addStyleTags + addScriptTags (default-to-head covered by prior comment).
- In endBodyTagHandler: only addScriptTags when in_body.
Illustrative diff (key parts):
@@
added_head_tags: bool,
+ added_style_tags: bool = false,
+ added_script_tags: bool = false,
- fn addHeadTags(this: *@This(), endTag: *lol.EndTag) !void {
+ fn addStyleTags(this: *@This(), endTag: *lol.EndTag) !void {
+ if (this.added_style_tags) return;
+ this.added_style_tags = true;
+ var html_appender = std.heap.stackFallback(256, bun.default_allocator);
+ const allocator = html_appender.get();
+ // CSS only
+ var array: bun.BoundedArray([]const u8, 1) = .{};
+ if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| {
+ const link_tag = bun.handleOom(std.fmt.allocPrintZ(allocator, "<link rel=\"stylesheet\" crossorigin href=\"{s}\">", .{css_chunk.unique_key}));
+ array.appendAssumeCapacity(link_tag);
+ }
+ defer for (array.slice()) |slice| allocator.free(slice);
+ for (array.slice()) |slice| try endTag.before(slice, true);
+ }
+
+ fn addScriptTags(this: *@This(), endTag: *lol.EndTag) !void {
+ if (this.added_script_tags) return;
+ this.added_script_tags = true;
+ var html_appender = std.heap.stackFallback(256, bun.default_allocator);
+ const allocator = html_appender.get();
+ if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| {
+ const script = bun.handleOom(std.fmt.allocPrintZ(allocator, "<script type=\"module\" crossorigin src=\"{s}\"></script>", .{js_chunk.unique_key}));
+ defer allocator.free(script);
+ try endTag.before(script, true);
+ }
+ }
@@
- fn endHeadTagHandler(...){
+ fn endHeadTagHandler(...){
if (this.linker.dev_server == null) {
- // see prior comment suggestion for default-to-head logic
- this.addHeadTags(end) catch return .stop;
+ // If body-first scripts, still put CSS in head
+ if (this.script_in_body) |in_body| {
+ if (in_body) {
+ this.addStyleTags(end) catch return .stop;
+ } else {
+ this.addStyleTags(end) catch return .stop;
+ this.addScriptTags(end) catch return .stop;
+ }
+ } else {
+ this.addStyleTags(end) catch return .stop;
+ this.addScriptTags(end) catch return .stop;
+ }
} else { ... }
}
@@
- fn endBodyTagHandler(...){
+ fn endBodyTagHandler(...){
if (this.linker.dev_server == null) {
- if (this.script_in_body) |in_body| {
- if (in_body) this.addHeadTags(end) catch return .stop;
- }
+ if (this.script_in_body) |in_body| {
+ if (in_body) this.addScriptTags(end) catch return .stop;
+ }
} else { ... }
}
@@
- fn endHtmlTagHandler(...){
+ fn endHtmlTagHandler(...){
if (this.linker.dev_server == null) {
- this.addHeadTags(end) catch return .stop;
+ // Final fallback: ensure both are present
+ this.addStyleTags(end) catch return .stop;
+ this.addScriptTags(end) catch return .stop;
} else { ... }
}This preserves script placement while keeping styles in head.
Add tests (see test comments) to lock behavior.
I'll verify the review comment by examining the actual implementation of the code in question.
🏁 Script executed:
#!/bin/bash
# Search for the file and examine addHeadTags, getCSSChunkForHTML, getJSChunkForHTML
fd "generateCompileResultForHtmlChunk.zig" --type fLength of output: 64
🏁 Script executed:
#!/bin/bash
# Find addHeadTags function and related CSS/JS injection calls
rg -n "fn addHeadTags|getCSSChunkForHTML|getJSChunkForHTML" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -A 20 -B 2Length of output: 2565
🏁 Script executed:
#!/bin/bash
# Examine the handlers and script_in_body logic
rg -n "endHeadTagHandler|endBodyTagHandler|endHtmlTagHandler" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -A 15 -B 2Length of output: 4021
🏁 Script executed:
#!/bin/bash
# Get broader context around lines 193-200 to see the exact flow
sed -n '180,210p' src/bundler/linker_context/generateCompileResultForHtmlChunk.zigLength of output: 1472
🏁 Script executed:
#!/bin/bash
# Check for added_head_tags and added_style_tags tracking
rg -n "added_head_tags|added_style_tags|added_script_tags" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -B 2 -A 2Length of output: 1031
🏁 Script executed:
#!/bin/bash
# Look at the struct definition to understand current state fields
rg -n "struct.*{|added_head_tags" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -A 30 | head -60Length of output: 116
🏁 Script executed:
#!/bin/bash
# Get the full addHeadTags implementation
rg -n "fn addHeadTags" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -A 40Length of output: 2471
🏁 Script executed:
#!/bin/bash
# Check if CSS links are already being generated separately somewhere
rg -n "rel=.*stylesheet|<link" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -B 5 -A 2Length of output: 1692
🏁 Script executed:
#!/bin/bash
# Look for script tag generation
rg -n '<script|type="module"' src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -B 5 -A 2Length of output: 1574
🏁 Script executed:
#!/bin/bash
# Get the file size and full structure overview
wc -l src/bundler/linker_context/generateCompileResultForHtmlChunk.zigLength of output: 68
🏁 Script executed:
#!/bin/bash
# List all function definitions to understand the module structure
rg -n "^\s*fn " src/bundler/linker_context/generateCompileResultForHtmlChunk.zigLength of output: 635
🏁 Script executed:
#!/bin/bash
# Check the full context around lines 193-210
sed -n '185,220p' src/bundler/linker_context/generateCompileResultForHtmlChunk.zigLength of output: 1659
🏁 Script executed:
#!/bin/bash
# Check how getJSChunkForHTML and getCSSChunkForHTML are used in current addHeadTags
ast-grep --pattern 'fn addHeadTags($_, $_) {
$$$
}'Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check what getJSChunkForHTML and getCSSChunkForHTML return
rg -n "getJSChunkForHTML|getCSSChunkForHTML" src/bundler/linker_context/generateCompileResultForHtmlChunk.zig -B 3 -A 3Length of output: 993
🏁 Script executed:
#!/bin/bash
# Final comprehensive view: cat the relevant sections
cat src/bundler/linker_context/generateCompileResultForHtmlChunk.zig | head -300 | tail -150Length of output: 6874
🏁 Script executed:
#!/bin/bash
# Look for the chunk definition and CSS/JS chunk properties
rg -n "chunk\." src/bundler/linker_context/generateCompileResultForHtmlChunk.zig | head -30Length of output: 887
🏁 Script executed:
#!/bin/bash
# Check if styles are already handled separately elsewhere
rg -n "addStyle|injectCSS|style_tag" src/bundler/linker_context/generateCompileResultForHtmlChunk.zigLength of output: 0
🏁 Script executed:
#!/bin/bash
# Get the complete file to review all the logic
cat src/bundler/linker_context/generateCompileResultForHtmlChunk.zigLength of output: 14554
CSS injected into is a real issue—refactor to separate CSS and JS injection
The code currently calls addHeadTags() from endBodyTagHandler (line 198) when scripts are found in body, which injects both CSS link and JS script tags before </body>. This violates HTML standards (CSS belongs in <head>) and creates FOUC risk.
Looking at the implementation:
getHeadTags()(lines 158–171) bundles CSS and JS togetherendBodyTagHandler(line 198) uses this bundle, placing both CSS and JS before</body>whenscript_in_body == true- No separate tracking for CSS vs. JS injection
The suggested refactoring is required: split into addStyleTags() (CSS only) and addScriptTags() (JS only), with separate tracking flags. Ensure CSS always injects to </head> (or stays in head if head exists), while JS respects original location (head or body).
🤖 Prompt for AI Agents
In src/bundler/linker_context/generateCompileResultForHtmlChunk.zig around lines
193–200, the endBodyTagHandler currently calls addHeadTags() which injects both
CSS and JS before </body>; refactor so CSS and JS are handled separately: create
addStyleTags() (CSS only) and addScriptTags() (JS only), add separate tracking
flags (e.g., styles_injected, scripts_injected and script_in_body) and update
getHeadTags() to return only head-specific style tags while a new
getScriptTags() returns script tags; ensure CSS is always injected to the head
path (use addStyleTags() from head insertion or head fallback) and update
endBodyTagHandler to call addScriptTags() (not addHeadTags()) when scripts are
in body, leaving CSS out of body injection and preserving original script
placement logic.
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig
Outdated
Show resolved
Hide resolved
| // Test script tags in head are preserved in head | ||
| itBundled("html/script-in-head", { | ||
| outdir: "out/", | ||
| files: { | ||
| "/index.html": ` | ||
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <link rel="stylesheet" href="./styles.css"> | ||
| <script src="./script.js"></script> | ||
| </head> | ||
| <body> | ||
| <h1>Hello World</h1> | ||
| </body> | ||
| </html>`, | ||
| "/styles.css": "body { background-color: blue; }", | ||
| "/script.js": "console.log('Script in head')", | ||
| }, | ||
| entryPoints: ["/index.html"], | ||
| onAfterBundle(api) { | ||
| const htmlContent = api.readFile("out/index.html"); | ||
|
|
||
| // Check that bundled script tag is in the head (before </head>) | ||
| const bodyCloseIndex = htmlContent.indexOf("</body>"); | ||
| const headCloseIndex = htmlContent.indexOf("</head>"); | ||
| const scriptIndex = htmlContent.indexOf("<script"); | ||
|
|
||
| expect(scriptIndex).toBeGreaterThan(-1); | ||
| expect(bodyCloseIndex).toBeGreaterThan(-1); | ||
| expect(headCloseIndex).toBeGreaterThan(-1); | ||
|
|
||
| // Script should come before head close and before body close | ||
| expect(scriptIndex).toBeLessThan(headCloseIndex); | ||
| expect(scriptIndex).toBeLessThan(bodyCloseIndex); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Head preservation test is good—add complementary cases
Add two more tests to lock edge behaviors:
- No-script-in-source: ensure default-to-head injection (after code fix).
- External-only script: ensure placement defaults to the external script’s section (head/body) or, if not detectable, head.
I can draft these tests with itBundled if you confirm desired behavior for external-only pages.
When bundling HTML that has no script tags in the source, we now inject the bundled scripts/CSS before </head> instead of before </html>. This is done by removing the immediate injection in endHtmlTagHandler for production bundling and handling it in post-processing instead, where we can search for </head> and insert at that position. The fix resolves the "basic plugin" test failure while maintaining all other test passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig(9 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.zig
📄 CodeRabbit inference engine (.cursor/rules/javascriptcore-class.mdc)
**/*.zig: Declare the extern C symbol in Zig and export a Zig-friendly alias for use
Wrap the Bun____toJS extern in a Zig method that takes a JSGlobalObject and returns JSC.JSValue
Files:
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig
src/**/*.zig
📄 CodeRabbit inference engine (.cursor/rules/building-bun.mdc)
When adding debug logs in Zig, create a scoped logger and log via Bun APIs:
const log = bun.Output.scoped(.${SCOPE}, .hidden);thenlog("...", .{})
src/**/*.zig: Use private fields in Zig with the#prefix (e.g.,struct { #foo: u32 };)
Prefer decl literals in Zig (e.g.,const decl: Decl = .{ .binding = 0, .value = 0 };)
Prefer placing@importstatements at the bottom of the Zig file (formatter may reorder automatically)
Prefer@import("bun")rather than@import("root").bunor@import("../bun.zig")In Zig code, be careful with allocators and use defer for cleanup
Files:
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig
🔇 Additional comments (2)
src/bundler/linker_context/generateCompileResultForHtmlChunk.zig (2)
293-303: Reasonable fallback for malformed HTML.The final fallback appends tags at the end when no
</head>tag is found, which is appropriate for severely malformed HTML. The@branchHint(.cold)correctly indicates this is an uncommon path.
263-305: Code logic is correct; cannot verify test execution in sandbox environment.Based on comprehensive code inspection of lines 263-305, the fallback injection logic for HTML with no source scripts appears sound:
What the code does:
- When
added_head_tagsis false (no scripts found during parsing), it attempts to inject CSS/JS head tags- Searches for
</head>tag; if found, inserts tags before it- Falls back to appending at end if
</head>is not found- Uses safe memory operations:
stackFallbackallocator, properdefercleanup, andstd.mem.copyBackwards()for overlapping regionsCode quality observations:
- Null handling of
indexOf()result is correct with optional binding|head_idx|- Memory safety: proper cleanup with
deferloop (lines 271-272, 299-302)- Offset calculations for buffer manipulation are correct
- Both success and fallback code paths are properly implemented
Limitations:
The original review requests runningbun test bundler_html.test.ts, which cannot execute in this environment (bun not available in sandbox). The code inspection shows no logical errors, but actual test verification with HTML inputs is not possible here.
| // Track if this is a script tag and where it's located | ||
| const is_script = kind == .stmt and loader.isJavaScriptLike(); | ||
| if (is_script and this.script_in_body == null) { | ||
| // First script tag - record its location | ||
| this.script_in_body = (this.current_section == .body); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Edge case: Scripts before <head> or <body> are treated as head-scripts.
When the first script appears before any <head> or <body> tags are encountered, current_section == .none, so script_in_body is set to false (since .none != .body). This treats early scripts as head-scripts.
For well-formed HTML this is fine, but for malformed documents with scripts appearing before structural tags, the behavior may be surprising.
Consider explicitly handling the .none case:
if (is_script and this.script_in_body == null) {
// First script tag - record its location
- this.script_in_body = (this.current_section == .body);
+ this.script_in_body = switch (this.current_section) {
+ .body => true,
+ .head, .none => false, // Treat scripts before head/body as head-scripts
+ };
}🤖 Prompt for AI Agents
In src/bundler/linker_context/generateCompileResultForHtmlChunk.zig around lines
81 to 86, the current logic records the first script's location even when
current_section == .none, causing scripts before <head> or <body> to be treated
as head scripts; change the logic to only set this.script_in_body when
current_section is either .head or .body (i.e., ignore .none), so skip recording
on .none and instead set script_in_body the first time a script is encountered
after a known section, or alternatively defer setting until you see the next
section transition and then assign based on that real section; implement the
simpler fix: guard the assignment with (this.current_section == .head or
this.current_section == .body) so scripts seen in .none do not flip
script_in_body.
| if (bun.strings.indexOf(html_loader.output.items, "</head>")) |idx| | ||
| break :brk @intCast(idx); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
String search for </head> may miss variations and is case-sensitive.
The fallback logic searches for the exact string "</head>" (lines 255, 266). This won't match variations like "</HEAD>", "</ head >", or "</head >", though these are uncommon in practice.
While the manual memory manipulation (lines 280-292) appears correct, consider:
- Documenting that the search is case-sensitive and requires exact formatting
- Using a more robust HTML-aware search if LOLHTML provides utilities
The current approach works for well-formed lowercase HTML but may fail on edge cases.
Also applies to: 266-292
🤖 Prompt for AI Agents
In src/bundler/linker_context/generateCompileResultForHtmlChunk.zig around lines
255-256 (and similarly 266-292), the code searches for the literal string
"</head>" which is case-sensitive and will miss valid variations (e.g.
"</HEAD>", extra whitespace). Update the search to be robust: either normalize
the buffer slice to lowercase and search for "</head>" in that normalized view,
or implement a small case-insensitive byte search that treats ASCII letters
equivalently and tolerates optional spaces inside the tag (e.g. match "</" +
optional spaces + "head" + optional spaces + ">"); alternatively, if LOLHTML
offers a utility for locating head end, call that. Replace the current exact
match checks with the new case-insensitive/whitespace-tolerant search and ensure
the subsequent manual byte manipulation uses the original buffer offsets (not
the normalized copy).
Summary
Fixes HTML bundler to respect the original location of script tags when generating bundled output. Previously, all scripts were moved to the head regardless of their original placement.
Changes
<head>stay in<head><body>stay in<body><head>(via html tag fallback)Test Results
Example
Before:
After:
🤖 Generated with Claude Code