Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
022e1a2993 Improve HTML bundler to respect original script tag location
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>
2025-10-25 02:01:46 +00:00
Claude Bot
ae666a4657 Fix HTML bundler to inject scripts at end of body instead of head
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>
2025-10-25 01:28:32 +00:00
2 changed files with 128 additions and 13 deletions

View File

@@ -49,6 +49,10 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
html: ?u32 = 0,
},
added_head_tags: bool,
/// Track where we found script tags: null = not found, false = in head, true = in body
script_in_body: ?bool = null,
/// Track which section we're currently in
current_section: enum { none, head, body } = .none,
pub fn onWriteHTML(this: *@This(), bytes: []const u8) void {
bun.handleOom(this.output.appendSlice(bytes));
@@ -58,7 +62,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
Output.panic("Parsing HTML during replacement phase errored, which should never happen since the first pass succeeded: {s}", .{err});
}
pub fn onTag(this: *@This(), element: *lol.Element, _: []const u8, url_attribute: []const u8, _: ImportKind) void {
pub fn onTag(this: *@This(), element: *lol.Element, _: []const u8, url_attribute: []const u8, kind: ImportKind) void {
if (this.current_import_record_index >= this.import_records.len) {
Output.panic("Assertion failure in HTMLLoader.onTag: current_import_record_index ({d}) >= import_records.len ({d})", .{ this.current_import_record_index, this.import_records.len });
}
@@ -74,6 +78,13 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
else
.file;
// 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);
}
if (import_record.is_external_without_side_effects) {
debug("Leaving external import: {s}", .{import_record.path.text});
return;
@@ -114,6 +125,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
}
pub fn onHeadTag(this: *@This(), element: *lol.Element) bool {
this.current_section = .head;
element.onEndTag(endHeadTagHandler, this) catch return true;
return false;
}
@@ -124,6 +136,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
}
pub fn onBodyTag(this: *@This(), element: *lol.Element) bool {
this.current_section = .body;
element.onEndTag(endBodyTagHandler, this) catch return true;
return false;
}
@@ -150,7 +163,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
array.appendAssumeCapacity(link_tag);
}
if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| {
// type="module" scripts do not block rendering, so it is okay to put them in head
// type="module" scripts do not block rendering, placement is determined by original script location
const script = bun.handleOom(std.fmt.allocPrintZ(allocator, "<script type=\"module\" crossorigin src=\"{s}\"></script>", .{js_chunk.unique_key}));
array.appendAssumeCapacity(script);
}
@@ -160,7 +173,12 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
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) {
this.addHeadTags(end) catch return .stop;
// Inject here if scripts were in head (script_in_body == false)
// or if no scripts were found yet (script_in_body == null)
const inject_in_head = if (this.script_in_body) |in_body| !in_body else false;
if (inject_in_head) {
this.addHeadTags(end) catch return .stop;
}
} else {
this.end_tag_indices.head = @intCast(this.output.items.len);
}
@@ -170,7 +188,12 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
fn endBodyTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive {
const this: *@This() = @alignCast(@ptrCast(opaque_this.?));
if (this.linker.dev_server == null) {
this.addHeadTags(end) catch return .stop;
// Inject here if scripts were in body (script_in_body == true)
// or if no scripts were found yet (script_in_body == null) - default to body
const inject_in_body = if (this.script_in_body) |in_body| in_body else true;
if (inject_in_body) {
this.addHeadTags(end) catch return .stop;
}
} else {
this.end_tag_indices.body = @intCast(this.output.items.len);
}
@@ -180,6 +203,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
fn endHtmlTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.C) lol.Directive {
const this: *@This() = @alignCast(@ptrCast(opaque_this.?));
if (this.linker.dev_server == null) {
// Fallback: inject at end of html if no head or body tags injected yet
this.addHeadTags(end) catch return .stop;
} else {
this.end_tag_indices.html = @intCast(this.output.items.len);
@@ -216,19 +240,36 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
sources[chunk.entry_point.source_index].contents,
) catch std.debug.panic("unexpected error from HTMLProcessor.run", .{});
// There are some cases where invalid HTML will make it so </head> is
// There are some cases where invalid HTML will make it so the end tag is
// never emitted, even if the literal text DOES appear. These cases are
// along the lines of having a self-closing tag for a non-self closing
// element. In this case, head_end_tag_index will be 0, and a simple
// search through the page is done to find the "</head>"
// element. In this case, we do a simple search through the page.
// See https://github.com/oven-sh/bun/issues/17554
const script_injection_offset: u32 = if (c.dev_server != null) brk: {
if (html_loader.end_tag_indices.head) |head|
break :brk head;
if (bun.strings.indexOf(html_loader.output.items, "</head>")) |head|
break :brk @intCast(head);
if (html_loader.end_tag_indices.body) |body|
break :brk body;
// Respect the user's original script location preference
const prefer_body = if (html_loader.script_in_body) |in_body| in_body else true;
if (prefer_body) {
// Try body first (user had scripts in body or no scripts at all)
if (html_loader.end_tag_indices.body) |body|
break :brk body;
if (bun.strings.indexOf(html_loader.output.items, "</body>")) |body|
break :brk @intCast(body);
// Fall back to head
if (html_loader.end_tag_indices.head) |head|
break :brk head;
} else {
// Try head first (user had scripts in head)
if (html_loader.end_tag_indices.head) |head|
break :brk head;
if (bun.strings.indexOf(html_loader.output.items, "</head>")) |head|
break :brk @intCast(head);
// Fall back to body
if (html_loader.end_tag_indices.body) |body|
break :brk body;
}
// Final fallback
if (html_loader.end_tag_indices.html) |html|
break :brk html;
break :brk @intCast(html_loader.output.items.len); // inject at end of file.

View File

@@ -843,4 +843,78 @@ body {
api.expectFile("out/" + jsFile).toContain("sourceMappingURL");
},
});
// Test script tags in body are preserved in body
itBundled("html/script-in-body", {
outdir: "out/",
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<h1>Hello World</h1>
<script src="./script.js"></script>
</body>
</html>`,
"/styles.css": "body { background-color: red; }",
"/script.js": "console.log('Hello World')",
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const htmlContent = api.readFile("out/index.html");
// Check that bundled script tag is in the body (before </body>), not in 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 after head close and before body close
expect(scriptIndex).toBeGreaterThan(headCloseIndex);
expect(scriptIndex).toBeLessThan(bodyCloseIndex);
},
});
// 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);
},
});
});