fix(bundler): include lazy chunks in frontend.files for compiled fullstack builds (#26024)

## Summary

- Fixed lazy-loaded chunks from dynamic imports not appearing in
`frontend.files` when using `--splitting` with `--compile` in fullstack
builds
- Updated `computeChunks.zig` to mark non-entry-point chunks as browser
chunks when they contain browser-targeted files
- Updated `HTMLImportManifest.zig` to include browser chunks from server
builds in the files manifest

Fixes #25628

## Test plan

- [ ] Added regression test `test/regression/issue/25628.test.ts` that
verifies lazy chunks appear in `frontend.files`
- [ ] Manually verified: system bun reports `CHUNK_COUNT:1` (bug), debug
bun reports `CHUNK_COUNT:2` (fix)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
robobun
2026-01-14 16:08:06 -08:00
committed by GitHub
parent c57d0f73b4
commit f27c6768ce
3 changed files with 106 additions and 1 deletions

View File

@@ -166,8 +166,14 @@ pub fn write(index: u32, graph: *const Graph, linker_graph: *const LinkerGraph,
defer already_visited_output_file.deinit(bun.default_allocator);
// Write all chunks that have files associated with this entry point.
// Also include browser chunks from server builds (lazy-loaded chunks from dynamic imports).
// When there's only one HTML import, all browser chunks belong to that manifest.
// When there are multiple HTML imports, only include chunks that intersect with this entry's bits.
const has_single_html_import = graph.html_imports.html_source_indices.len == 1;
for (chunks) |*ch| {
if (ch.entryBits().hasIntersection(&entry_point_bits)) {
if (ch.entryBits().hasIntersection(&entry_point_bits) or
(has_single_html_import and ch.flags.is_browser_chunk_from_server_build))
{
if (!first) try writer.writeAll(",");
first = false;

View File

@@ -229,6 +229,16 @@ pub noinline fn computeChunks(
.output_source_map = SourceMap.SourceMapPieces.init(this.allocator()),
.flags = .{ .is_browser_chunk_from_server_build = is_browser_chunk_from_server_build },
};
} else if (could_be_browser_target_from_server_build and
!js_chunk_entry.value_ptr.entry_point.is_entry_point and
!js_chunk_entry.value_ptr.flags.is_browser_chunk_from_server_build and
ast_targets[source_index.get()] == .browser)
{
// If any file in the chunk has browser target, mark the whole chunk as browser.
// This handles the case where a lazy-loaded chunk (code splitting chunk, not entry point)
// contains browser-targeted files but was first created by a non-browser file.
// We only apply this to non-entry-point chunks to preserve the correct side for server entry points.
js_chunk_entry.value_ptr.flags.is_browser_chunk_from_server_build = true;
}
const entry = js_chunk_entry.value_ptr.files_with_parts_in_chunk.getOrPut(this.allocator(), @as(u32, @truncate(source_index.get()))) catch unreachable;

View File

@@ -0,0 +1,89 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/25628
// Bug: Lazy code-splitting chunks are not accessible via frontend.files in fullstack builds
// when using --splitting with --compile. The chunks are physically written to disk and embedded
// in the executable, but they're filtered out when accessing the embedded files array.
test("lazy chunks from code splitting should appear in frontend.files", { timeout: 60000 }, async () => {
using dir = tempDir("issue-25628", {
// Server entry that prints frontend.files and exits
"server.ts": `
import frontend from "./client.html";
// Get all file paths from frontend.files
const filePaths = frontend.files?.map((f: any) => f.path) ?? [];
// Count the number of chunk files (lazy chunks are named chunk-xxx.js)
const chunkCount = filePaths.filter((p: string) =>
p.includes("chunk-")
).length;
// There should be at least 2 chunks:
// 1. The main app entry chunk
// 2. The lazy-loaded chunk from the dynamic import
console.log("CHUNK_COUNT:" + chunkCount);
console.log("FILES:" + filePaths.join(","));
// Exit immediately after printing
process.exit(0);
`,
"client.html": `<!DOCTYPE html>
<html>
<head>
<script type="module" src="./main.js"></script>
</head>
<body></body>
</html>`,
"main.js": `
// Dynamic import creates a lazy chunk
const lazyMod = () => import("./lazy.js");
lazyMod().then(m => m.hello());
`,
"lazy.js": `
export function hello() {
console.log("Hello from lazy module!");
}
`,
});
// Build with splitting and compile
await using buildProc = Bun.spawn({
cmd: [bunExe(), "build", "--compile", "server.ts", "--splitting", "--outfile", "server"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [buildStdout, buildStderr, buildExitCode] = await Promise.all([
buildProc.stdout.text(),
buildProc.stderr.text(),
buildProc.exited,
]);
expect(buildStderr).not.toContain("error:");
expect(buildExitCode).toBe(0);
// Run the compiled executable
const serverPath = isWindows ? "server.exe" : "./server";
await using runProc = Bun.spawn({
cmd: [serverPath],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [runStdout, runStderr, runExitCode] = await Promise.all([
runProc.stdout.text(),
runProc.stderr.text(),
runProc.exited,
]);
// There should be at least 2 chunk files in frontend.files:
// one for the main entry and one for the lazy-loaded module
expect(runStdout).toMatch(/CHUNK_COUNT:[2-9]/);
expect(runExitCode).toBe(0);
});