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

perf: improve performance of Link and routes #1453

Merged
merged 1 commit into from
Apr 15, 2024

Conversation

chorobin
Copy link
Contributor

@chorobin chorobin commented Apr 9, 2024

TS Performance Improvments

Purpose

Type checking large code bases with many Route's and Link's can get quite sluggish. This PR introduces a number of performance optimizations I've discovered while profiling on the large file based example. The purpose of this PR is to improve suggestion times for such code bases and build times

Extended Diagnostics

On main the following diagnostics are present for the example large file based

> tsc --extendedDiagnostics

Files:                         589
Lines of Library:            38118
Lines of Definitions:        77132
Lines of TypeScript:         12777
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                113478
Symbols:                    351283
Types:                       62925
Instantiations:            1541766
Memory used:               311542K
Assignability cache size:   113357
Identity cache size:          3707
Subtype cache size:              0
Strict subtype cache size:  162006
I/O Read time:               0.08s
Parse time:                  0.55s
ResolveModule time:          0.11s
ResolveTypeReference time:   0.01s
ResolveLibrary time:         0.02s
Program time:                0.84s
Bind time:                   0.24s
Check time:                  5.64s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  6.72s

After the changes introduced in this PR the instantiations and check time are reduced

> tsc --extendedDiagnostics

Files:                         589
Lines of Library:            38118
Lines of Definitions:        77133
Lines of TypeScript:         12777
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                113461
Symbols:                    189848
Types:                       50776
Instantiations:             592405
Memory used:               222842K
Assignability cache size:    34415
Identity cache size:          3718
Subtype cache size:              0
Strict subtype cache size:       0
I/O Read time:               0.07s
Parse time:                  0.54s
ResolveModule time:          0.11s
ResolveTypeReference time:   0.01s
ResolveLibrary time:         0.02s
Program time:                0.83s
Bind time:                   0.23s
Check time:                  3.82s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  4.88s

Profiling

This is the profile for the large file route on main.

image

Going deep into each wall of the profile. The biggest wall is the route tree definition. There is a number of things to improve here.

  • Performance of each individual file route types
  • Variance checks on each Route
  • Type parameter constraints on each Route
  • Improving performance of merging properties from each parent route
  • Only Expand when necessary and not eagerly

image

And the next significant thing is each Link. Each Link can be improved by the following:

  • Separating LinkComponentProps to cache props for a component passed to createLink
  • Use the resolved route for relative paths to narrow down the union of paths relevant for autocomplete
  • Use CheckPath directly on the to prop to avoid an intersection
  • Evaluate less of params and search until the user use these props

image

After the performance improvements the route tree looks more like this

image

and each Link looks like this

image

Link may seem like a small improvement for one Link but when you have many in large code bases this quickly accumulates

Copy link

nx-cloud bot commented Apr 9, 2024

☁️ Nx Cloud Report

CI is running/has finished running commands for commit f3ba3a1. 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.

@chorobin chorobin force-pushed the f/perf-link-routes branch 2 times, most recently from 0ac0db2 to 39d4484 Compare April 10, 2024 16:45
@@ -603,10 +603,10 @@ export function useLoaderData<
...opts,
select: (s) => {
return typeof opts.select === 'function'
? opts.select(s.loaderData)
? opts.select(s.loaderData as TRouteMatch)
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't this be TRouteMatch['loaderData']?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah. This is weird. I will have to double check why this was complaining

@chorobin chorobin force-pushed the f/perf-link-routes branch from 39d4484 to cbc692c Compare April 10, 2024 17:24

export type FileRoutePath<
TParentRoute extends AnyRoute,
TFilePath extends string,
TResolvedFilePath = ResolveFilePath<TParentRoute, TFilePath>,
> = TResolvedFilePath extends `_${infer _}`
> = TResolvedFilePath extends `_${string}`
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a simple change but reduces instantiations because the type variable is not used

string,
any
> = TSearchSchemaInput extends SearchSchemaInput
TSearchSchemaUsed = TSearchSchemaInput extends SearchSchemaInput
Copy link
Contributor Author

@chorobin chorobin Apr 10, 2024

Choose a reason for hiding this comment

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

For type parameters which act more like variables, its better not to use generic constraints on them. This is because typescript will do eager checking on the value you assign to it.

Copy link
Contributor

Choose a reason for hiding this comment

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

should we remove the RouteConstraints type then fully?

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 don't want to say constraints are bad because you need them to check generics inferred by the user

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But if it's not inferred by the user, I assume it can be trusted

@@ -190,7 +173,6 @@ export class FileRoute<
? undefined
: TLoaderDataReturn,
TChildren extends RouteConstraints['TChildren'] = unknown,
TRouteTree extends RouteConstraints['TRouteTree'] = AnyRoute,
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 don't think this was needed

@@ -84,67 +84,78 @@ export type RemoveLeadingSlashes<T> = T extends `/${infer R}`
? RemoveLeadingSlashes<R>
: T

export type ResolvePaths<TRouteTree extends AnyRoute, TSearchPath> =
Copy link
Contributor Author

@chorobin chorobin Apr 10, 2024

Choose a reason for hiding this comment

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

For relative routes starting with '../' and '.' we can narrow the union of the paths down to the descendents of the resolved path.

Basically, because we know TFrom is a route, we can actually only get descendants of the resolved route. This can make relative pathing faster because we're not dealing with such a large union

TPaths,
TSearchedPaths = SearchPaths<TPaths, TSearchPath>,
> = TSearchedPaths extends string ? `${TTo}/${TSearchedPaths}` : never
> = `${TTo}/${SearchPaths<TRouteTree, TSearchPath>}`
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was a silly mistake by me before. Template literal types automatically distribute. Its a small optimization but it does help a bit

@@ -189,13 +200,13 @@ export type ToSubOptions<
// The source route path. This is automatically set when using route-level APIs, but for type-safe relative routing on the router itself, this is required
from?: RoutePathsAutoComplete<TRouteTree, TFrom>
// // When using relative route paths, this option forces resolution from the current path, instead of the route API's path or `from` path
} & CheckPath<TRouteTree, {}, TFrom, TTo> &
Copy link
Contributor Author

@chorobin chorobin Apr 10, 2024

Choose a reason for hiding this comment

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

Intersections are slow because we have to merge a union with another union for to. Better just to change to prop directly

TFrom,
TToParams,
> = TParamVariant extends 'SEARCH'
? Expand<{
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Think I need to do a few more Expand

@@ -644,30 +713,32 @@ export type LinkProps<
| ((state: { isActive: boolean }) => React.ReactNode)
}

type LinkComponentProps<TComp> = React.PropsWithoutRef<
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is nice. Basically because TComp doesn't change on every Link we can seperate these props into a seperate type and TS will cache this for all Link's

@@ -14,7 +14,7 @@ export type RoutesById<TRouteTree extends AnyRoute> = {
}

export type RouteById<TRouteTree extends AnyRoute, TId> = Extract<
Extract<ParseRoute<TRouteTree>, { id: TId }>,
RoutesById<TRouteTree>[TId],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Turns out mapped types are faster than Extract on a union ;). Makes sense when you think about it. But it only shows when you start to have larger code bases

TRouteTree extends AnyRoute = AnyRoute,
TDehydrated extends Record<string, any> = Record<string, any>,
TSerializedError extends Record<string, any> = Record<string, any>,
in out TRouteTree extends AnyRoute = AnyRoute,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can make these invariant to stop TS from doing variance checks on them. I don't think they're necessary in this case

// eslint-disable-next-line @typescript-eslint/naming-convention
export type MakeDifferenceOptional<T, U> = Omit<U, keyof T> &
Partial<Pick<U, keyof T & keyof U>> &
PickRequired<Omit<U, keyof PickRequired<T>>>
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'm not sure why this existed but it works without it?

Copy link
Contributor

Choose a reason for hiding this comment

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

I added this here:

92923cc#diff-5fd7a0f05758a78cf8b73c09e4d71d21af63fe3d9b312b0ee75b04041ef078e7R222

do we have a test case that tests if when navigating e.g. from /foo/$bar to /foo/$bar/baz/$id, $bar is optional whereas $id is 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.

Yes. I thought this was covered by the code already here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the closest test case but maybe I should write a specific one!

test('when navigating from a route to a route with the same params', () => {

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah would be cool to have that


params.returns.branded.toEqualTypeOf<{ invoiceId: string }>()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We should always Expand instead of relying on branded in tests. It means we have complicated intersections

@@ -102,13 +101,12 @@ test('when creating a child route with a loader from the root route with context
const invoicesRoute = createRoute({
path: 'invoices',
getParentRoute: () => rootRoute,
loader: async () => [{ id: 'invoice1' }, { id: 'invoice2' }] as const,
loader: async (opts) => {
expectTypeOf(opts).toMatchTypeOf<{ context: { userId: string } }>()
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 decided this approach is better because its testing the inference flow

@chorobin chorobin force-pushed the f/perf-link-routes branch from cbc692c to f3ba3a1 Compare April 15, 2024 14:43
@chorobin chorobin marked this pull request as ready for review April 15, 2024 14:44
@@ -183,19 +194,19 @@ export type ToSubOptions<
TFrom extends RoutePaths<TRouteTree> | string = string,
TTo extends string = '',
> = {
to?: ToPathOption<TRouteTree, TFrom, TTo>
to?: ToPathOption<TRouteTree, TFrom, TTo> & {}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a simple way to expand the complex type so it appears nice

@schiller-manuel schiller-manuel merged commit 3ece907 into TanStack:main Apr 15, 2024
7 checks passed
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