diff --git a/src/bundler/HTMLImportManifest.zig b/src/bundler/HTMLImportManifest.zig index 7827370e94..d2716e45aa 100644 --- a/src/bundler/HTMLImportManifest.zig +++ b/src/bundler/HTMLImportManifest.zig @@ -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; diff --git a/src/bundler/linker_context/computeChunks.zig b/src/bundler/linker_context/computeChunks.zig index 3c906826e9..2abc07404b 100644 --- a/src/bundler/linker_context/computeChunks.zig +++ b/src/bundler/linker_context/computeChunks.zig @@ -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; diff --git a/test/regression/issue/25628.test.ts b/test/regression/issue/25628.test.ts new file mode 100644 index 0000000000..e44fef7708 --- /dev/null +++ b/test/regression/issue/25628.test.ts @@ -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": ` + + + + + +`, + "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); +});