Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-router): allow narrowing of matches with useMatches #2058

Merged
merged 7 commits into from
Aug 23, 2024

Conversation

chorobin
Copy link
Contributor

@chorobin chorobin commented Jul 29, 2024

useMatches returns a union of all matches

Currently useMatches returns single match type with different properties which are a union of everything. loaderData is a union of all loaderData, routeId is a union of all routeId, search is a union of all search. The problem with this approach is its impossible for TS to narrow a match based on the routeId of the match.

For example this code doesn't type check in TS 5.5:

  const match = useMatches({
    select: (matches) =>
      matches.find((match) => match.routeId === '/posts/$postId'),
  })

  const postId = match?.params.postId // the type is not narrowed :(

Another example that would not type check

export const PageTitle = () => {
  const matches = useMatches()
  return (
    <h1>
      {matches.map((match) => {
        switch (match.routeId) {
          case '/dashboard/invoices/$invoiceId':
            return match.loaderData?.title // the match should be narrowed here to specific `loaderData`
          case '/dashboard/users/user':
            return match.loaderData?.username
        }
      })}
    </h1>
  )
}

But now it does with this PR

This is because TS 5.5 can narrow a match based on the routeId of a match. This is made possible by creating a match type for each route which is a part of a union

isMatch type predicate (idea?)

Narrowing a union based on routeId isn't always feasible. Sometimes you want to narrow to matches which contain a certain shape or data. For example, a match might contain data necessary to build breadcrumbs and we want to find matches with the necessary data.

This can be achieved with the isMatch type predicate

export const Breadcrumbs = () => {
  const matches = useMatches({
    select: (matches) =>
      matches.filter((match) => isMatch(match, 'loaderData.crumb')),
  })

  return (
    <nav>
      <ul>
        {matches.map((match, i) => (
          <li>
            <Link from={match.fullPath}>{match.loaderData?.crumb}</Link>
          </li>
        ))}
      </ul>
    </nav>
  )
}

isMatch will narrow a the type of matches to only matches which contain loaderData.crumb. TypeScript is happy because the match types are narrowed

Copy link

nx-cloud bot commented Jul 29, 2024

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 18faefc. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this CI Pipeline Execution


✅ Successfully ran 2 targets

Sent with 💌 from NxCloud.

Copy link

pkg-pr-new bot commented Jul 29, 2024

commit: 18faefc

@tanstack/history

pnpm add https://pkg.pr.new/@tanstack/history@2058

@tanstack/react-cross-context

pnpm add https://pkg.pr.new/@tanstack/react-cross-context@2058

@tanstack/react-router

pnpm add https://pkg.pr.new/@tanstack/react-router@2058

@tanstack/react-router-with-query

pnpm add https://pkg.pr.new/@tanstack/react-router-with-query@2058

@tanstack/router-arktype-adapter

pnpm add https://pkg.pr.new/@tanstack/router-arktype-adapter@2058

@tanstack/router-cli

pnpm add https://pkg.pr.new/@tanstack/router-cli@2058

@tanstack/router-devtools

pnpm add https://pkg.pr.new/@tanstack/router-devtools@2058

@tanstack/router-generator

pnpm add https://pkg.pr.new/@tanstack/router-generator@2058

@tanstack/router-plugin

pnpm add https://pkg.pr.new/@tanstack/router-plugin@2058

@tanstack/router-valibot-adapter

pnpm add https://pkg.pr.new/@tanstack/router-valibot-adapter@2058

@tanstack/router-vite-plugin

pnpm add https://pkg.pr.new/@tanstack/router-vite-plugin@2058

@tanstack/router-zod-adapter

pnpm add https://pkg.pr.new/@tanstack/router-zod-adapter@2058

@tanstack/start

pnpm add https://pkg.pr.new/@tanstack/start@2058

@tanstack/start-vite-plugin

pnpm add https://pkg.pr.new/@tanstack/start-vite-plugin@2058

Open in Stackblitz

More templates

match: TMatch,
path: ValidateMatchSuggestions<TMatch, TPath>,
): match is SuggestMatchesByPath<TPath, TMatch>['match'] => {
const parts = path.split('.')
Copy link
Contributor Author

@chorobin chorobin Aug 1, 2024

Choose a reason for hiding this comment

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

This runtime logic is really basic. I'm going to be adding tests so I assume it might get better if we like the idea

TValue = TMatch,
TParentPath extends string = '',
> = TPath extends `${string}.${string}`
? TPath extends `${infer TFirst}.${infer TRest}`
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Optimised the types a bit but I have some ideas to go further if we like it

@@ -1140,6 +1140,7 @@ export class Router<
scripts: route.options.scripts?.(),
staticData: route.options.staticData || {},
loadPromise: createControlledPromise(),
fullPath: route.fullPath,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it would be good to have fullPath on a match to enable <Link from={match.fullPath} />

Comment on lines +8 to +10
const matchesWithCrumbs = matches.filter((match) =>
isMatch(match, 'loaderData.crumb'),
)
Copy link
Contributor

Choose a reason for hiding this comment

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

looking at the sandbox, matchesWithCrumbs is always RouteMatch<any, any, any, any, any, any, any, any>[]:

Screenshot 2024-08-02 at 17 01 52

Is that because the sandbox is using the wrong TS version?

Copy link
Contributor

Choose a reason for hiding this comment

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

also - why couldn't we just check for match.loaderData?.crumb !== undefined ?

Copy link
Contributor Author

@chorobin chorobin Aug 2, 2024

Choose a reason for hiding this comment

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

I noticed this. It's weird. Works well in vscode and sometimes it works in the sandbox but not all the time

Copy link
Contributor Author

Choose a reason for hiding this comment

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

also - why couldn't we just check for match.loaderData?.crumb !== undefined ?

The issue is TS is not able to narrow this. If you have loader data in matches which do not have crumb, you can't get to the property without using in. Furthermore TS is not able to narrow the parent type of a property if it's not a literal type. This is why routeId works but crumb does not. Discriminated unions work but other properties do not

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I noticed this. It's weird. Works well in vscode and sometimes it works in the sandbox but not all the time

Maybe the types just need optimising more. I think there's still too much instantiations happening in the recursive type due to it distributing over the recursive type. Maybe stackblitz is more limited in resources or uses an older version of TS which kicks the bucket with more complex types?

@chorobin chorobin marked this pull request as ready for review August 23, 2024 22:50
@chorobin chorobin merged commit 00ab942 into main Aug 23, 2024
5 checks passed
@chorobin chorobin deleted the use-matches branch August 23, 2024 23:04
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.

2 participants