Skip to content

FileSystemRouter: match a dynamic route with uppercase letters by its on-disk spelling - #43003

Open
robobun wants to merge 1 commit into
mainfrom
robobun/07edd9a3/fsr-dynamic-route-uppercase
Open

robobun wants to merge 1 commit into
mainfrom
robobun/07edd9a3/fsr-dynamic-route-uppercase

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • pages/Blog/[Slug].tsx is listed by router.routes as /Blog/[Slug], but match("/Blog/x") returns null. Only /blog/x matches. With a second route [section]/[id].tsx, /Users/1 matches that route and not Users/[id].tsx.
  • The cause is Pattern::match_ (src/router/lib.rs:1187). It compares the static segments of the lowercased route name to the URL byte for byte. Nothing lowercases the URL.
  • A static route does not have this problem. append_route (:413) keys About.tsx by About and by about.

Fix

  • Routes::match_dynamic tries each dynamic route with its lowercased name. If the route has an uppercase letter, it then tries the on-disk name.
  • A dynamic route now accepts the same two spellings as a static route. /BLOG/x still does not match. A route tree with no uppercase letter takes the same path as before.
  • The retry clears params when it fails, because a failed Pattern::match_ can leave params in the list (Bun.FileSystemRouter router match unknown behaviour #12206, open fix in FileSystemRouter: reset params on every failed candidate and fix single-char trailing segments #36680). Without the clear, /help/Settings against [user]/Settings/[...rest].tsx plus help/[...topic].tsx fails with assertion failed: name_slice.length > 0.
  • Verified: test/js/bun/util/filesystem_router.test.ts. The new test fails on 1.4.3, and all 36 tests pass with the fix. Also test/regression/issue/18242.test.ts.

Background

  • Route.name is the route name as spelled on disk. Route.match_name is the same string in lowercase, with the same length, so param offsets apply to both.
  • Static routes live in a hash table. Dynamic routes ([id], [...rest], [[...rest]]) live in a sorted list. match_dynamic returns the first one that matches.
  • params is one scratch list that all candidates in that walk share.
Notes

Repro

import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";

const d = mkdtempSync(tmpdir() + "/fsr-case-");
mkdirSync(d + "/Blog");
writeFileSync(d + "/About.tsx", "export default 1;\n");
writeFileSync(d + "/Blog/[Slug].tsx", "export default 1;\n");
const r = new Bun.FileSystemRouter({ style: "nextjs", dir: d });
for (const u of ["/About", "/about", "/Blog/x", "/blog/x"]) console.log(u, "->", r.match(u)?.name ?? null);
URL 1.4.3 this branch
/About /About /About
/about /About /About
/Blog/x null /Blog/[Slug]
/blog/x /Blog/[Slug] /Blog/[Slug]

History. The Zig router had the same logic, so this is not a port regression. Commit 8d03e0c (2021) added the second hash table key for static routes with uppercase letters. Dynamic routes did not get an equivalent.

Probes, 1.4.3 against this branch. I ran 10 route trees and 55 URLs: index plus dynamic below an uppercase directory, uppercase only in the param name, query strings, percent-encoded paths, Request inputs, absolute URL strings, nested uppercase segments, a root catch-all next to an uppercase dynamic route, an uppercase file extension, and an optional catch-all. The only outputs that change are URLs in the on-disk spelling. Each one now gives the result that its lowercased spelling already gives. That includes the existing behavior that /blog matches Blog/[Slug].tsx with Slug: "". /Blog now does the same.

Why the retry cannot see stale params. Both attempts walk the same pattern structure. Only the static comparisons can differ. So the retry can match only if the first attempt failed at a static segment, and that exit clears params.

Why not full case-insensitive matching. It changes which route wins in trees with no uppercase letter. Example: about.tsx plus [id].tsx. Today /ABOUT matches /[id] with id: "ABOUT". That is a separate decision, and the change to make later is small: compare the static segments without case in Pattern::match_ and drop the retry.

Related open PRs. #36680 rewrites Pattern::match_ so that a failed match truncates params. After it lands, the params.clear() here is redundant and harmless. #33444 fixes the #fragment bug on the same match() entry point.


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

… on-disk spelling

`pages/Blog/[Slug].tsx` is listed as `/Blog/[Slug]`, but `match("/Blog/x")`
returned null. Only `/blog/x` matched. `Pattern::match_` compares the static
segments of the lowercased route name to the URL byte for byte, and nothing
lowercases the URL.

`match_dynamic` now tries each dynamic route with its lowercased name, then
with its on-disk name. These are the two spellings the static route table
already keys. The retry clears `params` when it fails, so it adds no stale
params for the next candidate.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 4 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 1 second for your next included review.

Check out review usage here.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ae9a4600-1bbb-40a8-bfd1-7b554b0bfb34

📥 Commits

Reviewing files that changed from the base of the PR and between b64b630 and e3c20b8.

📒 Files selected for processing (2)
  • src/router/lib.rs
  • test/js/bun/util/filesystem_router.test.ts

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

@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction, on bun 1.4.3 (Linux x64):

import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";

const d = mkdtempSync(tmpdir() + "/fsr-case-");
mkdirSync(d + "/Blog");
writeFileSync(d + "/Blog/[Slug].tsx", "export default 1;\n");
const r = new Bun.FileSystemRouter({ style: "nextjs", dir: d });
console.log(r.match("/Blog/x")?.name ?? null); // null, expected /Blog/[Slug]
console.log(r.match("/blog/x")?.name ?? null); // /Blog/[Slug]

With this branch both lines print /Blog/[Slug].

@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.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Comment thread src/router/lib.rs Resolved

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