Skip to content

bake: fail bun build --app when two files match the same route pattern - #47146

Open
robobun wants to merge 1 commit into
mainfrom
robobun/08629995/bake-build-app-fail-on-route-collision
Open

robobun wants to merge 1 commit into
mainfrom
robobun/08629995/bake-build-app-fail-on-route-collision

Conversation

@robobun

@robobun robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #42836, #39488

Problem

  • bun build --app prints error: Multiple pages matching the same route pattern is ambiguous. Then it writes dist/ with one of the two files and exits with code 0.
  • Which file ships follows hash order: a new signup.tsx swapped about.tsx for about/index.tsx.
  • Cause: RouteScan::on_router_collision_error prints and does not count (src/runtime/bake/production/entry_points.rs:43). build_with_vm stops only when the count is above 0 (production/mod.rs:605).

Fix

  • The collision arm counts its report in error_count (renamed). The build then exits with code 1 before it bundles or writes dist/.
  • Correct: every collision shape ends in this one arm.
  • Verified: test/bake/dev/production.test.ts (route collisions): 9 of 10 cases fail before, 10 pass after. Also test/cli/bake/bake.test.ts and 4 more suites.
  • Self-reviewed: 22 concerns raised, 21 addressed (Notes).

Background

Downsides

  • Behavior change (canary builds, or BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE=1): every collision report fails the build. next build accepts 3 of 6 shapes silently.
  • Two such reports are not user mistakes: a layout in each of two route groups, and a router prefix (bake: mount each file system router on its prefix #42904).
  • Release binary: +6 bytes in the collision arm. CI size check: +0.0 KB on 11 targets, +16.1 KB on darwin-aarch64. A clean build never enters the arm.
Notes

Source and reach. An automated test run of bun build --app found this. No user reported it, and no issue quotes the message. --app needs a canary or debug build, or BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE=1. Release 1.4.4 with that variable has the bug. The original handler (src/bake/production.zig, dc01a5d) was a copy of the dev server's handler, which prints and keeps serving.

Repro. A framework with no dependencies: app.ts with one fileSystemRouterTypes entry (root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts"), and a server.ts that exports render, prerender and getParams. On main 9b8347a (debug and release builds), on canary 1.4.4-canary.1+a0cc096ce and on release 1.4.4 with the variable:

$ bun build --app ./app.ts     # routes/about.ts beside routes/about/index.ts
error: Multiple pages matching the same route pattern is ambiguous
  - routes/about/index.ts
  - routes/about.ts
Rendering routes
done
$ echo $?
0
$ ls dist/about
index.html        # the page of about.ts only

With this PR: the same three error lines, no Rendering routes, no done, no dist/, exit code 1.

Which file ships. The scan walks the names of a directory in hash bucket order, and the first file keeps the route. Tree: routes/ with about.tsx, about/index.tsx, blog.tsx, contact.tsx, index.tsx, login.tsx, pricing.tsx. Main ships about.tsx. Add routes/signup.tsx and it ships about/index.tsx. Remove it and about.tsx is back. Exit code 0 each time. #40731 (open) sorts the scan. This applies to a collision inside one router. Across routers the first entry wins.

Collision shapes. Both RouteCollision arms of FrameworkRouter::insert (router/mod.rs:373, :410) reach the one handler arm, so the one line covers all of them. The cases: a file beside the index file of a directory, one file name with two extensions, [id] beside [slug], a route group (nextjs-app-ui), one path in two routers, one file in two routers, two layouts of one directory, and two collisions in one build (both are reported before the exit).

A URL that two router types claim. It fails the build now, also when it is one file: a router entry listed twice, or two entries on one root whose files overlap. The report then lists the file twice. Before, the first entry kept the URL and the second never rendered it. Two types on one root that share no URL still build (nextjs-app-ui beside nextjs-app-routes). The control case pins that.

next build. I ran Next.js 16.4.3 on six shapes. It fails three: [id] beside [slug], a page in a route group beside the same path outside it, one path in app/ and in pages/. It builds three with no message: about.js beside about/index.js, one name with two extensions, two layout files in one directory. This PR fails all of them. Bun already prints error: for each, names no winner, and the winner follows hash order.

Tests. All in test/bake/dev/production.test.ts.

  • Block route collisions: 8 collision cases and 1 control, each one bun build --app child process. The fixture's framework has prerender and getParams, so a build that ignores the collision completes, and the exit code can tell the two apart. minimalFramework has no prerender and exits with code 1 for that reason.
  • The control (routers that share no URL build) has four router types, two of them on one root, with a static and a dynamic route. It compares every file in dist/.
  • two pages resolving to the same route are reported (the built-in React framework) now also asserts exit code 1 and no dist/. No existing assertion changed.
  • Without the fix, 9 of 10 fail and the control passes (release build of main 9b8347a). Each collision case fails at the one toEqual, with exit code 0, done on stdout and dist/ written. A first version of the block (7 collision cases) gave the same result on a debug build (ASAN) of that commit and on the canary.
  • With the fix, 10 of 10 pass: a debug build (ASAN) of this branch, and a release build.
  • Release build with the fix, whole files: production.test.ts 30 pass, test/cli/bake/bake.test.ts 85 pass, framework-router.test.ts 37 pass, app-options.test.ts 13 pass, dev/ssg-pages-router.test.ts 10 pass, dev-and-prod.test.ts 12 pass. No other test file runs bun build --app.
  • Debug build on my machine: the React cases of production.test.ts take 4.4 s to 5.0 s each, and 5 to 8 of them reach the 5 s limit in a run, on main and with this PR.
  • Not run: bun run rust:mordant. Its pinned nightly and cargo mordant are not installed on my machine. CI runs it.

Measurements. Two release builds (LTO) in one build directory: main 9b8347a, and the same tree with this change. A second build of the change gave a byte-identical binary. strace, perf, valgrind and bloaty are not installed on my machine, so the numbers come from llvm-nm -S, llvm-objdump -d, gdb and a ptrace syscall counter.

  • Release binary: RouteScan::on_router_collision_error 511 to 517 bytes of .text (+6: one incq, and two register encodings that are 1 byte longer), 0 bytes of data. The stripped binary has 109,037,072 bytes in both builds, and .text has 83,497,513 bytes in both.
  • Clean bun build --app (3 pages): the changed function is entered 0 times (gdb breakpoint count). scan_inner, insert, scan, scan_all and build_with_vm have the same instructions in both builds (1274, 774, 95, 48 and 2520).
  • Collision build (about.ts beside about/index.ts), 3 runs each: exit code 1 (was 0), mkdir and open calls below dist 0 (was 2 and 6), threads started 0 (was 12 to 14), syscalls 255 (was 658 to 760).
  • Clean build, 3 runs each: mkdir and open calls below dist 5 and 9 on main, 5 and 9 with this change.
  • Test load: +9 concurrent bun build --app child processes in test/bake/dev/production.test.ts.
  • CI's binary size check of this PR against main #134092 (the same base commit): +0.0 KB on 11 targets, +16.1 KB on bun-darwin-aarch64. I measured the 6 bytes on linux x64 and did not inspect the macOS binary. Its segments are aligned to 16 KB pages.

Reports that are not a user mistake. Both fail the build now. An exemption for them was considered and rejected: the build they let through is wrong.

  • Route groups: shop/(a)/layout.tsx beside shop/(b)/layout.tsx (nextjs-app-ui, layouts: true) is reported as a layout collision. The scan drops a route group from a static pattern (router/mod.rs:749, :809), so both layouts land on the shop route. The dev server then serves every page with one group's layout. A build of such a tree exits with code 0 on main only when no page is rendered with a layout: a page below a layout fails at prerender with TypeError: No default value (src/js/builtins/Bake.ts:132). bake: consolidate the open robobun dev-server, HMR runtime, production build and router fixes #39488 has a router change for this (commit bfcffab). It must land no later than the prerender fix. Both are handed off.
  • prefix: the router ignores it, so pages/users.ts beside api/users.ts with prefix: "/api" is reported as one pattern. Main dropped api/users.ts and exited with code 0. bake: mount each file system router on its prefix #42904 mounts each router on its prefix.

Not in this PR. Each of the first five still exits with code 0 on a build of this PR.

Earlier work. The same count is in #42836 (open, conflicts with main), in #39174 (closed), and in commit fdf1604 of #39488. #46021 merged RouteScan and the exit from #42836 without the collision line ("crash fix only"), and said that PR stays open for the rest. After this PR, what is left in #42836 is the test binding, a private TinyLog::write, and a message wording.

Self-review. 22 concerns: 5 about tests and about wording that was false, 17 about what this text must say. Changed: the control has several routers, a case for one file in two routers, the React case pins the exit code, a failed case prints the child's stderr, the title, and the Downsides and Notes text above. Not done: I did not remove the same hunk from the branch of #42836. That branch does not apply to main. I posted the relation there.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/production.test.ts

`RouteScan::on_router_collision_error` printed the collision and did not
count it. The build went on, wrote dist/ with one of the two files and
exited with code 0. The arm now counts the report, as the arm for an
invalid route file does, so the build exits with code 1 before it
bundles.

The counter is now `error_count`, because it counts both kinds of route
error.
@coderabbitai

coderabbitai Bot commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 24 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 11c3671 and 977d462.

Files selected for processing: 3
  • src/runtime/bake/production/entry_points.rs
  • src/runtime/bake/production/mod.rs
  • test/bake/dev/production.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for a maintainer. The one decision is the exit code: bun build --app now exits with code 1 after a route collision report.

CI (build 134270). Every test-bun lane that has run passed: Linux (with ASAN), Windows, and macOS arm64. test/bake/dev/production.test.ts ran on the ASAN and Windows lanes. The two macOS x64 test-bun jobs have not started: they expired three times in the queue with no free agent, and were retried. The only red jobs are test-aot-drawn. That lane does not fail a build, and it is also red on main (build 134092).

How I reproduced it. A framework with no dependencies:

// app.ts
export default { app: { framework: { fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }] } } };

// server.ts
export function render(req, meta) { return new Response(String(meta.pageModule.default())); }
export async function prerender(meta) { return { files: { "/index.html": String(meta.pageModule.default()) } }; }

// routes/about.ts
export default () => "routes/about.ts";

// routes/about/index.ts
export default () => "routes/about/index.ts";

Run bun build --app ./app.ts. On main 9b8347a (debug and release builds), on canary 1.4.4-canary.1+a0cc096ce, and on release 1.4.4 with BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE=1:

error: Multiple pages matching the same route pattern is ambiguous
  - routes/about/index.ts
  - routes/about.ts
Rendering routes
done

The exit code is 0, and dist/about/index.html holds the page of routes/about.ts only.

With this PR the build prints the same three error lines, then stops: no done, no dist/, exit code 1.

PR: #47146

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the mechanics of the change and found nothing else: the invalid_routes rename has no stale call sites in src/, the exit goes through the same Global::crash() the syntax-error arm already used, and the dev server's handler at src/runtime/bake/dev_server/router_glue.rs:137 prints without counting by design (it keeps serving across hot reloads), so it is not a missed sibling site. The remaining question is the policy one raised inline, which is a maintainer call.

Extended reasoning...

The change adds one counter increment to the collision arm of RouteScan in src/runtime/bake/production/entry_points.rs, renames the counter, and extends test/bake/dev/production.test.ts with a concurrent test.each matrix of collision fixtures plus a positive control; no security-sensitive surface is touched. The code is small and the test coverage is strong, but the inline finding is a behavior-policy question (which router collision reports should be fatal for bun build --app) that the author acknowledges as a downside, so a human maintainer should decide it rather than this review approving.

))
);
Output::flush();
self.error_count += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users with valid app layouts, such as a layout in each of two route groups, or two routers with different prefix values, can no longer run bun build --app at all. The collision arm at entry_points.rs:62 now counts every report the router emits, and the router also reports shapes that are not real URL collisions: route groups are dropped from the pattern (router/mod.rs:809), so shop/(a)/layout.tsx and shop/(b)/layout.tsx collide at router/mod.rs:373, and prefix is never passed into router::Type (production/mod.rs:572-593), so two prefixed routers sharing a file name collide. Fix: before the count can fail the build, the router must only report real URL collisions, honoring prefix in the pattern and keeping per-group layout nodes.

Why this was flagged

A bun build --app project uses nextjs-app-ui with layouts: true and a layout in each of two route groups, e.g. shop/(a)/layout.tsx and shop/(b)/layout.tsx; or two fileSystemRouterTypes entries with different prefix values whose roots both contain index.ts. scan_all builds the static pattern with Part::Group(_) => {} (src/runtime/bake/router/mod.rs:809), so both layouts land on the same route node and insert returns RouteCollision at src/runtime/bake/router/mod.rs:373; prefix is parsed (src/runtime/bake/config/framework.rs:471) but never placed in router::Type at src/runtime/bake/production/mod.rs:572-593, so prefixed routers collide too. on_router_collision_error now does self.error_count += 1 (src/runtime/bake/production/entry_points.rs:62) and build_with_vm calls Global::crash() at src/runtime/bake/production/mod.rs:606. On the base the same tree printed the message and still wrote dist/ with exit 0; after merge it prints "Multiple layout matching the same route pattern is ambiguous" and exits 1 with no dist/.

Verification: src/runtime/bake/router/mod.rs:801-813 builds the static pattern with Part::Group(_) => {}; insert at mod.rs:368-373 returns Err(InsertError::RouteCollision). src/runtime/bake/production/entry_points.rs:62 adds self.error_count += 1;, and production/mod.rs:605-607 calls Global::crash(), so both shapes now exit 1 before bundling.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The facts are right, and this is the trade-off in Downsides. No code change for now. Two points for the maintainer who decides.

Neither shape builds correctly on main today. I ran both on a release build of main 9b8347a.

  • (a)/layout.tsx beside (b)/layout.tsx, with a page in each group: main already exits with code 1. It prints the collision report, then TypeError: No default value at prerender (src/js/builtins/Bake.ts:132, a separate defect for any page below a layout). With that defect worked around, main exits with code 0 and renders page x of group (a) with the layout of group (b):
    dist/x/index.html: x layouts=b-layout
    dist/y/index.html: y layouts=b-layout
    
  • Two routers that differ only in prefix (pages/users.ts beside api/users.ts with prefix: "/api"): main prints the report, leaves api/users.ts out of dist/, and exits with code 0.

The one build that worked and now fails is a tree with two group layouts where no page is rendered with a layout.

The router fixes are separate changes. #42904 mounts each router on its prefix. Commit bfcffab in #39488 gives a route group its own layout node, and a port of it to main is in work. Until they land, this PR makes the build stop with a report where main writes a wrong dist/. If the preferred order is router fixes first, this PR can wait for them.

I leave this thread open for that decision.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants