Repository navigation
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
Conversation
- 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.
|
@brophdawg11 Would you have time to take a look at this one? It's up to date with |
|
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 |
|
I found the same
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 (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.
|
There was still some room for tuning, so I've pushed a few more optimizations. On top of the original changes, they make
¹ 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
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. |
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 andapplyToEnvironment, has the.serverguard 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) andreact-router buildfrom 13–17s to 8–9s on the same machine, with identical output.Some background on how we got here.
react-router buildwas 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 theroute-exportsandroute-chunks-indextransforms 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.splitRouteModulesdetection, 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.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,applyandapplyToEnvironment. The regexes come from the existing id constants.react-router:dot-serverresolved every client import throughthis.resolve()and then returnedundefined, so Vite resolved the same import again. It now returns what it resolved, includingmeta,externalandmoduleSideEffectsfrom custom resolvers.route-exportsbuilt 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 theUNSAFE_with*Propsimport. It now traverses without scope tracking and checks the source text for the candidate name instead, and it only asks@babel/generatorfor a source map in dev or when the environment'sbuild.sourcemapis 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_TIMINGSalso no longer lists theroute-chunks-indextransform or thevirtual-modulesload 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
.serverguard preserves resolution results without resolving twice. Both fail onmainand pass here, on the Vite 7 and Vite 8 templates. Build, typecheck, lint, format,changes:validate, the jestdevproject 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.