Skip to content

fix(trie-router): count every slash a pattern consumes - #5189

Merged
yusukebe merged 2 commits into
honojs:mainfrom
Jaybhade:fix/trie-router-multi-part-pattern-offset
Aug 5, 2026
Merged

yusukebe merged 2 commits into
honojs:mainfrom
Jaybhade:fix/trie-router-multi-part-pattern-offset

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

When a :param{regexp} match spans several path parts, Node#search requeues the child into curNodesQueue[componentCount] so it resumes at the part after the match. componentCount came from:

const componentCount = m[0].match(/\//)?.length ?? 0

Without the g flag, String.prototype.match returns a single-match array, so .length is 1 for any number of slashes. A pattern spanning three or more parts resumed one part too early and matched a part it had already consumed.

Two parts happen to give the right answer (one slash → 1), which is why the existing /foo/bar/file.html case passes and this went unnoticed.

Symptoms

Both reproduce on a plain new Hono() — RegExpRouter reports UnsupportedPathError for these routes, so SmartRouter falls through to TrieRouter.

A route stops matching once the pattern spans 3+ parts. /:dirs{.+}/file.html is the repo's own Capture simple multiple directories route:

path before after
/foo/file.html 200 200
/foo/bar/file.html 200 200
/foo/bar/baz/file.html 404 200
/a/b/c/d/file.html 404 200

A handler is returned twice, so middleware runs twice per request:

const app = new Hono()
let runs = 0
app.use('/:path{.*}/*', async (c, next) => { runs++; await next() })
app.get('/a/b/c', (c) => c.text('ok'))

await app.request('/a/b/c')
// before: runs === 2
// after:  runs === 1

At the raw router level:

const r = new TrieRouter()
r.add('GET', '/:file{.*}/*', 'handler')
r.match('GET', '/a/b/c')
// before: [[['handler', {file:'a/b/c'}], ['handler', {file:'a/b/c'}]]]
// after:  [[['handler', {file:'a/b/c'}]]]

RegExpRouter and PatternRouter already return the correct result in every case above; this brings TrieRouter in line.

Fix

Count with a global regexp so the offset reflects every slash the pattern consumed.

Tests

  • common.case.test.ts — extended Capture simple multiple directories with 3- and 4-directory paths. It runs for every router, so it pins the behaviour across all of them; all pass unchanged, no new skip entries needed.
  • trie-router/node.test.ts — added Pattern spanning multiple parts, covering both the duplicate handler and the missed match at increasing depth.

Without the one-line change these fail with expected 2 to be 1 and expected +0 to be 1.

I also fuzzed ~197k random route sets across all four routers looking for any handler returned twice from a single match(): 16 distinct reproductions before, 0 after.

The author should do the following, if applicable

  • Add tests
  • Run tests — bun run test: 146 files, 4758 passed / 35 skipped
  • bun run format:fix && bun run lint:fix to format the code — prettier clean, 0 lint errors
  • Add TSDoc/JSDoc to document the code — n/a, internal branch in Node#search; left an inline comment on the counting logic instead

When a `:param{regexp}` match spans several path parts, the node is
requeued so it resumes at the part following the match. The number of
parts to skip was taken from `m[0].match(/\//)?.length`, but without the
`g` flag `String.prototype.match` returns at most one match, so the
count was 1 for any number of slashes.

A pattern spanning three or more parts therefore resumed one part too
early and matched a part it had already consumed:

- `/:dirs{.+}/file.html` did not match `/foo/bar/baz/file.html`
- `/:file{.*}/*` returned its handler twice for `/a/b/c`

Two parts happened to give the right answer, which is why the existing
`/foo/bar/file.html` case passed.
@codecov

codecov Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.70%. Comparing base (192768f) to head (41d9b67).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #5189   +/-   ##
=======================================
  Coverage   79.70%   79.70%           
=======================================
  Files         155      155           
  Lines       10889    10889           
  Branches     2280     2280           
=======================================
  Hits         8679     8679           
  Misses       2210     2210           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@usualoma

usualoma commented Aug 4, 2026

Copy link
Copy Markdown
Member

Hi @Jaybhade,
This is just a simple bug. Thanks for fixing it!

Since Hono doesn't include code comments for this level of processing, I think you should just delete that part.

@Jaybhade

Jaybhade commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Done in 41d9b67 — comment removed, the change is just the g flag now.

@yusukebe yusukebe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@yusukebe

yusukebe commented Aug 5, 2026

Copy link
Copy Markdown
Member

@Jaybhade Thank you!

@yusukebe
yusukebe merged commit 569b419 into honojs:main Aug 5, 2026
20 checks passed
BlankParticle pushed a commit to BlankParticle/hono that referenced this pull request Sep 6, 2026
* fix(trie-router): count every slash a pattern consumes

When a `:param{regexp}` match spans several path parts, the node is
requeued so it resumes at the part following the match. The number of
parts to skip was taken from `m[0].match(/\//)?.length`, but without the
`g` flag `String.prototype.match` returns at most one match, so the
count was 1 for any number of slashes.

A pattern spanning three or more parts therefore resumed one part too
early and matched a part it had already consumed:

- `/:dirs{.+}/file.html` did not match `/foo/bar/baz/file.html`
- `/:file{.*}/*` returned its handler twice for `/a/b/c`

Two parts happened to give the right answer, which is why the existing
`/foo/bar/file.html` case passed.

* chore(trie-router): drop the explanatory comment

---------

Co-authored-by: Jayesh Bhade <[email protected]>
delhombre added a commit to delhombre/typelapse that referenced this pull request Oct 13, 2026
* fix(diff): a scene holds at most a readable number of lines

One card serves every scene, so the tallest frame sizes them all: the hero's
example PR (honojs/hono#5189) fitted to a 56-row frame drew its code at 9.4 px
in the 1080p export. The walkthrough is now split in pages of at most 24 rows
(18.8 px), a window taller than a page continues in the next scene behind a
separator. Adds a read-only px measurement over the shared fit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(diff): the total budget is a page budget, hono#5189 shows node.ts

MAX_WALKTHROUGH_PAGES = 8 replaces the 50-line total (the line budget is
derived: pages x MAX_SCENE_LINES), the pages are cut at 8 when their scaffold
pushes past it. The hero's example PR now shows all three files, no marker.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(diff): the omission marker counts the files a page cut hides

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(diff): a walkthrough plays at most 4 pages (19.7 s for the longest PR)

Measured: the three hero examples fit 4 pages (14.2, 17.9, 14.2 s) and the
246-file comparison lands at 11 steps, 19.7 s where 8 pages ran 40 s.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(diff): interleave a block's changes, caption pages by their own hunk, marker on the last page

- a cut inside a rewritten block keeps each old line facing its new line
- a continuation of an addition has no near-empty opening frame
- a page opens on its own first hunk's caption, never the previous page's
- the omission marker sits on the last page only
- the px helper moves under tests/support (test-only code)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants