Skip to content

Layout routes cause optional params to fail to match #5912

Closed

Description

@CynicalBusiness

Which project does this relate to?

Router

Describe the bug

Taking the example route from the documentation of posts/{-$category}, this is documented to match both /posts and /posts/category.

While this normally works as expected, if a "route" component exists, such as this:

posts/
  {-$category}.tsx
  route.tsx

/posts now fails to match anything. The route.tsx is rendered but the outlet is just left empty. If route.tsx is removed, the category route is rendered as expected.

This looks related to things like #5799 and #5635, but the fix attached to both those issues seems to have already landed and did not resolve this issue.

Your Example Website or App

https://stackblitz.com/edit/tanstack-router-3ccfbxyn?file=src%2Froutes%2Fposts.%7B-%24category%7D.tsx

Steps to Reproduce the Bug or Issue

  1. Visit the StackBlitz link (modified from the "Basic (file-based)" example)
  2. Navigate to "Posts"
  3. Observe that "no category" fails to match, but categories A and B are fine.
  4. Rename/delete posts.route.tsx and observe the "no category" route rendering fine.

Expected behavior

The URL /posts matches posts/{-$category}.tsx as expected/documented.

Screenshots or Videos

Example from the router devtools in my app:
Image

Despite the route being /workspaces/some-workspace-id, the {-$folderId} route does not match.

Platform

  • Router / Start Version: 1.136.17
  • OS: Windows
  • Browser: Both Chrome/Edge
  • Browser Version: >= 142.0
  • Bundler: vite
  • Bundler Version: 7.1.7

Additional context

No response

Activity

  1. self-assigned this
    on Nov 20, 2025
  2. Sheraff commented on Nov 20, 2025

    @Sheraff
    Collaborator

    Trying to write my thoughts down so I can debug this later:

    If we go to /posts should we match /posts/{-$category} or /posts?

    • current behavior is /posts, so just the layout with an empty outlet
    • if route.tsx is not there, then /posts will match /posts/{-$category}

    I feel like this issue is kinda correct. If I were to try and formulate a rule it would be

    • if /posts is an exact match and is an index then favor that over the optional child
    • if /posts does not exist or is a layout route then favor the optional child

    it is implementable but

    • our possibilities of early return are even smaller
    • some of the unit tests fail (but these are edge-cases that are hard to have a strong opinion on)

    Both of these would now fail, and match the other route instead

    it('/optional/static/static vs /static/static', () => {
      const tree = makeTree(['/{-$other}/posts/new', '/posts/new'])
      expect(findRouteMatch('/posts/new', tree)?.route.id).toBe('/posts/new')
    })
    it('edge-case: ???', () => {
      // This test comes from the previous processRouteTree tests.
      // > This demonstrates that `/foo/{-$p}.tsx` will be matched, not `/foo/{-$p}/{-$x}.tsx`
      // > This route has 1 optional parameter, making it more specific than the route with 2
      const tree = makeTree(['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx'])
      expect(findRouteMatch('/foo', tree)?.route.id).toBe('/foo/{-$p}.tsx')
    })

    Either way could be argued, I'm not sure what is the correct match here. Would be interesting to see what other routers do.


    To implement this, change

    • perfect match early return condition
      // perfect match, no need to continue
      if (statics === partsLength && node.isIndex) return bestMatch
    • stack frame comparison function
      function isFrameMoreSpecific(
        // the stack frame previously saved as "best match"
        prev: MatchStackFrame<any> | null,
        // the candidate stack frame
        next: MatchStackFrame<any>,
      ): boolean {
        if (!prev) return true
        return (
          next.statics > prev.statics ||
          (next.statics === prev.statics &&
            (next.dynamics > prev.dynamics ||
              (next.dynamics === prev.dynamics &&
                ((next.node.isIndex && !prev.node.isIndex) ||
                  next.depth > prev.depth))))
        )
      }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions