diff --git a/src/runtime/api/filesystem_router.zig b/src/runtime/api/filesystem_router.zig index 814927a97ce..5d447283f74 100644 --- a/src/runtime/api/filesystem_router.zig +++ b/src/runtime/api/filesystem_router.zig @@ -256,10 +256,22 @@ pub const FileSystemRouter = struct { return globalThis.throw("Unable to find directory: {s}", .{this.router.config.dir}); }; + // Deep-copy config strings into the new arena. The previous arena is freed below, so + // any slice that still points into it (the inner extension strings, asset_prefix_path) + // would dangle on the *next* reload() and be read by loadRoutes. dupe(string, ...) only + // copies the outer []string; each inner []const u8 must be duped individually. + const extensions = extensions: { + const old = this.router.config.extensions; + const new = allocator.alloc(string, old.len) catch unreachable; + for (old, new) |ext, *out| { + out.* = allocator.dupe(u8, ext) catch unreachable; + } + break :extensions new; + }; var router = Router.init(vm.transpiler.fs, allocator, .{ .dir = allocator.dupe(u8, this.router.config.dir) catch unreachable, - .extensions = allocator.dupe(string, this.router.config.extensions) catch unreachable, - .asset_prefix_path = this.router.config.asset_prefix_path, + .extensions = extensions, + .asset_prefix_path = allocator.dupe(u8, this.router.config.asset_prefix_path) catch unreachable, }) catch unreachable; router.loadRoutes(&log, root_dir_info, Resolver, &vm.transpiler.resolver, router.config.dir) catch { // Build the JS error before freeing the arena: `log` is backed by the arena allocator. diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 87d13cd6351..a3ff06a82d8 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -368,6 +368,41 @@ it("reload() works", () => { expect(router.match("/posts")!.name).toBe("/posts"); }); +it("reload() preserves custom fileExtensions and assetPrefix across multiple reloads", () => { + // Regression: reload() shallow-copied the []string of extensions into the new arena but + // left the inner []const u8 pointing into the old arena (which is then freed). The second + // reload() would scan routes against dangling extension bytes, dropping every route (and + // tripping ASAN). asset_prefix_path was not copied at all. + const { dir } = make(["index.tsx", "posts/[id].tsx", "posts.tsx"]); + + const router = new Bun.FileSystemRouter({ + dir, + style: "nextjs", + fileExtensions: [".tsx"], + assetPrefix: "/_next/static/", + origin: "https://nextjs.org", + }); + + const expected = { + "/": `${dir}/index.tsx`, + "/posts": `${dir}/posts.tsx`, + "/posts/[id]": `${dir}/posts/[id].tsx`, + }; + + expect(router.routes).toEqual(expected); + + // First reload() happens while the original arena is still live during loadRoutes, + // so it appears to work even with the shallow copy. The second and subsequent reloads + // read extension strings that were freed by the previous reload. + for (let i = 0; i < 5; i++) { + router.reload(); + expect(router.routes).toEqual(expected); + const { name, src } = router.match("/posts/hello-world")!; + expect(name).toBe("/posts/[id]"); + expect(src).toBe("https://nextjs.org/_next/static/posts/[id].tsx"); + } +}); + it("reload() works with new dirs/files", () => { const { dir } = make(["posts.tsx"]);