Skip to content

perf(vite): compile route modules once per build, filter the hooks that ran on every module and skip Babel scope tracking in the route transform - #15476

Open
huuyafwww wants to merge 11 commits into
remix-run:mainfrom
huuyafwww:perf/vite-plugin-build-performance
Open

huuyafwww wants to merge 11 commits into
remix-run:mainfrom
huuyafwww:perf/vite-plugin-build-performance

Conversation

@huuyafwww

@huuyafwww huuyafwww commented Sep 8, 2026 •

Copy link
Copy Markdown

Hi, and thanks for all the work on React Router. We've been running framework mode in production for a while and wanted to give a bit back after digging into our build times.

This caches the child compiler's output per build so each route module is compiled once instead of three times, makes getRoute() a map lookup instead of a scan over the route manifest, gives the hooks that only care about specific ids or one side of the build proper filters and applyToEnvironment, has the .server guard return what it resolved instead of letting Vite resolve the same import again, and drops Babel's scope tracking from the route module transform. For our ~210-route SSR app that takes route compilations from 623 down to 208 (a 67% drop) and react-router build from 13–17s to 8–9s on the same machine, with identical output.

Some background on how we got here. react-router build was spending most of its time inside the React Router Vite plugin rather than in the bundler, and profiling turned up a few things that scale with the number of routes and modules:

  • getRoute() did a linear scan over the route manifest, and it's called from the route-exports and route-chunks-index transforms for every module. That was ~170µs per non-route module for us, ~14k calls per build. It now uses a map keyed by the normalized route file, built once per resolved config.
  • Every route module was compiled by the child compiler three times per build (client route entry, server manifest exports, and splitRouteModules detection, which also ran serially). The compiled result is now shared within a build (never in dev or watch mode), and chunk detection looks at the exports we already have before compiling again, so exports added by other plugins still work.
  • A handful of hooks ran for every module or environment even though they only care about specific ids or one side of the build (build-client-route, split-route-modules, route-chunks-index, dot-client, the virtual module and HMR runtime plugins, the CSS modules manifest transform, react-refresh-babel). They now use hook filters, apply and applyToEnvironment. The regexes come from the existing id constants.
  • react-router:dot-server resolved every client import through this.resolve() and then returned undefined, so Vite resolved the same import again. It now returns what it resolved, including meta, external and moduleSideEffects from custom resolvers.
  • route-exports built a full Babel scope for every route module on both sides of the build, but the only thing the server side used it for was picking an unused name for the UNSAFE_with*Props import. It now traverses without scope tracking and checks the source text for the candidate name instead, and it only asks @babel/generator for a source map in dev or when the environment's build.sourcemap is on. That is about 0.4s of main-thread time on our build, with the same identifiers in the output.

On the output: I diffed the server bundle after normalizing the build id and compared the client asset names including hashes, and nothing changed. rolldown's PLUGIN_TIMINGS also no longer lists the route-chunks-index transform or the virtual-modules load after this.

There are two new integration tests: one checks that a route is compiled once per build and still recompiled on the next build (including exports generated by another plugin), the other checks that the .server guard preserves resolution results without resolving twice. Both fail on main and pass here, on the Vite 7 and Vite 8 templates. Build, typecheck, lint, format, changes:validate, the jest dev project and the Vite build / dot-server / dot-client / split-route-modules / HMR / prerender / CSS / SPA mode / dev / basename integration suites are green on Chromium.

Happy to split this up if it's easier to review in pieces.

huuyafwww and others added 2 commits September 8, 2026 15:08
- Derive the hook filter regexes from the existing route chunk, build
  client route and virtual module id constants so they cannot drift
- Drop guard clauses that only duplicate the hook filters and the
  `applyToEnvironment` / `apply` conditions
- Filter the CSS modules manifest transform on the main plugin so it is
  no longer called for every module

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lu6rFATiWxNFZgfA6R1AbH
…d-performance

# Conflicts:
#	packages/react-router-dev/vite/plugin.ts
The filter accepted any id whose query string ended in a script
extension, such as Vite's `index.html?html-proxy&index=0.js`, while the
check it replaced only looked at the path before the query.
`decorateComponentExportsWithProps` only used Babel's scope to pick an
unused identifier for the `react-router` HOC imports, but building that
scope is most of the cost of traversing a route module on the server
side, where nothing else has crawled it yet. The traversal now runs with
`noScope` and checks the source text for the candidate name instead,
producing the same `_UNSAFE_with*Props` identifiers as before.

The transform also always asked `@babel/generator` for a source map. It
now only does so in dev, or when the environment build has `sourcemap`
enabled, since a map for a build that discards it is wasted work.
@huuyafwww huuyafwww changed the title perf(vite): cache compiled route modules in a per-build Map to significantly reduce route compilations perf(vite): compile route modules once per build, filter the hooks that ran on every module and skip Babel scope tracking in the route transform Sep 16, 2026
@huuyafwww

Copy link
Copy Markdown
Author

@brophdawg11 Would you have time to take a look at this one? It's up to date with main and the numbers in the description are fresh: on our ~210-route SSR app on Vite 8 it takes react-router build from 13–17s to 8–9s, about 40% faster, with identical output. Happy to split it into smaller PRs if that's easier to review.

@matheuspedrososeg

Copy link
Copy Markdown

I migrated our SPA app to the framework mode with Vite v8.1.5 + SSR + TS 7, and I saw our build times going from about 6 seconds to 26 seconds.

This implementation seems to have fixed it, and it restored it back to 6 seconds, it would be nice if this went to production as it would considerably reduce our application deploy time

@tycrimm

tycrimm commented Nov 7, 2026

Copy link
Copy Markdown

I found the same getRoute() scan on my own before I saw this PR, so here's an independent data point for the review. I ran react-router build on generated apps with R routes, each importing 10 modules. The numbers are getRoute's inclusive CPU from a bun --cpu-prof profile, with only a Map index added, the same idea as the change here:

routes (modules) before after
400 (4.4k) 877 ms 41 ms
800 (8.8k) 2,578 ms 70 ms
1,600 (17.6k) 7,249 ms 451 ms

At 1,600 routes, whole-profile CPU went from 25.6 s to 16.7 s, and the client asset hashes were identical. This covers only the getRoute part of the PR, not the child compiler caching. Thanks @huuyafwww for putting it together.

(Measured with help from Claude Code.)

`route-exports` ran for every module in both environments and
`route-chunks-index` for every client module, and each call looked the
module up with `getRoute()`, which costs a `path.relative()`. The route
files can't change during a build, so both hooks now get a filter on the
route file names, and only those modules reach the handler, which still
does the full `getRoute()` check. Dev keeps calling the `route-exports`
transform for every module since routes can be added while it runs.
…ports

The code that reaches the route module transform is plain JavaScript, so
the exports it looks for, and the assignments to removed exports, can
only be top-level statements. These traversals now go through the
program body instead of walking the whole module each time.
During a build, the `.server` guard resolved every client import a
second time through `this.resolve()`, about 23k calls for 5.6k modules
in our app. Builds now look at each module's resolved imports in
`moduleParsed` instead, and only when one of them is a `.server` module
resolve the importer's specifiers again to report the one that pointed
there, so the error message stays the same. Dev keeps the `resolveId`
check.
The child compiler only compiles route modules to read their exports
and, for route chunk detection, their code, and neither depends on where
their imports resolve. Vite's import analysis still resolved every
import of every route module there, so the child compiler now answers
resolutions that have an importer as external.
@huuyafwww

Copy link
Copy Markdown
Author

There was still some room for tuning, so I've pushed a few more optimizations. On top of the original changes, they make react-router build another 0.9s faster and save 2.6s of CPU time. Here's what each one does:

Bottleneck Change Build time saved CPU time saved
route-exports and route-chunks-index ran for every module (12.7k and 5.6k calls) and looked each one up with getRoute() During builds, both hooks get a filter on the route file names, so only route modules reach the handler <0.1s 0.6s
removeExports and the UNSAFE_with*Props decoration walked the whole AST of each route module to find top-level statements They only look at the program body now <0.1s 0.1s¹
The .server guard ran for every client import and re-entered the resolver through this.resolve() (23.6k calls) Builds check each module's resolved imports once in moduleParsed instead, with the same error message 0.2s 1.2s
The child compiler's import analysis resolved every import of every route module (about 2.4k resolutions), though reading exports doesn't need them The child compiler answers those resolutions as external 0.5s 0.9s

¹ 0.12s less main-thread time measured inside the transform, which is below the run-to-run noise of a whole build.

With everything in this PR, the build time goes from 13.9s on main to 9.0s, and CPU time goes from 25.3s to 13.5s. So:

  • Build time: 1.55x faster
  • CPU time: 1.87x less

These are medians of 4 interleaved runs on our app (234 routes, Vite 8.3.6, Node 24.22.1, Apple M4), and the build output is identical in every case.

Slow builds have been a real pain for us, so we'd really appreciate it if you could merge this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants