Skip to content

refactor(compiler)!: replace Babel with a Yuku-based compiler core for Router and Start - #8817

Draft
Sheraff wants to merge 1463 commits into
mainfrom
compiler-rewrite/yuku
Draft

Sheraff wants to merge 1463 commits into
mainfrom
compiler-rewrite/yuku

Conversation

@Sheraff

@Sheraff Sheraff commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Includes the oracle test-suite branch compiler-tests/baseline. That branch adds the oracle (packages/compiler-test-utils) and pins main's bugs as .fails. This PR replaces the compilers and turns the pins green.

Summary

Every TanStack build-time compiler now runs on one shared core built on Yuku. That covers route code splitting and route HMR (router-plugin), all of Start's transforms (server functions, environment separation, <Hydrate>, RSC CSS, import protection) and the route generator. Babel, babel-dead-code-elimination and the Prettier hot path are gone.

On main, Babel took ~90% of compiler time. On 333- and 1,485-route React and Solid apps, builds are 2.1–4.8× faster and dev cold start is 2.1–5.0× faster. Start builds also end with 3–10× less heap.

Each module and environment gets one parse and one plan. Outputs no longer depend on transform or request order, and source maps are exact. The oracle's known-bug pins drop from 874 to 45 (36 of those keep main's behaviour on purpose), and 29 open issues are fixed.

Architecture

Full map: packages/router-utils/src/compiler/README.md.

  • Analysis → plan → emit. Each (env, module, text) gets one analysis and one plan. The plan emits the env module plus derived modules (?tsr-split=…, ?tss-hydrate=…, ?tss-serverfn-split, ?tss-serverfn-handler) and facts (import edges, server-fn registry, diagnostics, dependencies).
  • Planners are the jobs. Each one adds edits and roots to the shared plan, in a fixed order: start-env → hydrate → rsc-css → route-hmr → route-split → import-edges. APIs are recognised by binding, through an abstract value domain. Cross-module summaries come from raw text, never from the bundler pipeline.
  • Emit builds a rewrite tree over the original source ranges, so user code is never re-printed. OutputBuilder writes VLQ maps directly and composes them with input maps.
  • Reachability = splitting = dead-code removal, at cluster granularity. Code is removed only when our own edits orphaned it, so user side effects and unread imports stay. One mutation model (who writes each binding, and when) decides what may move. If a split does not settle within its re-planning bound, the route stays unsplit and a warning is emitted.
  • Lazy server-fn handlers (when SSR is the provider). The handlers, and what only they reach, move to ?tss-serverfn-handler, which loads on the first call. The defining module runs once.
  • Verifier (TSR_COMPILER_VERIFY=1, on in all tests). Every output is re-parsed and checked: no assignment to an import, glue imports resolve, each effect is emitted once, and extracted outputs make no eager env-module reads.
  • Caching and hosts. Caching is a plan cache, a dependency ledger and one invalidate(); no Yuku objects are retained. We own the hosts: a native Vite plugin, a Rspack/webpack loader, and Start Rsbuild loaders (with buildInfo replay for the persistent cache). unplugin remains only for the generator and esbuild.

Performance

Measured with benchmarks/compiler-perf at 774eba05a4 against main da2adec76d. Cells marked ² were re-measured at e80188ee7b. Both checkouts run in parallel on mirrored core sets, over 6 paired rounds; values are medians. Times are in ms and heap in MB. Large = 1,485 route files.

large app build dev cold → hydrated compile-all HMR route edit dev heap after GC
react-start Vite 30,418 → 9,016 24,801 → 7,401 31,105 → 8,583 65.0 → 41.6 704 → 207
react-start Rsbuild ² 27,484 → 6,143 32,702 → 6,501 – 1,839 → 941 733 → 207
react-router Vite 11,208 → 3,058 11,647 → 2,989 26,984 → 5,026 53.8 → 31.4 113 → 89.7
solid-start Vite 42,999 → 20,019 30,690 → 10,299 43,902 → 19,417 193 → 30.2 ³ 618 → 205
solid-router Vite 17,167 → 8,025 ² 14,349 → 4,354 40,273 → 15,426 876 (full reload) → 18.6 107 → 89.7

³ On main, this HMR run aborted in 5 of 6 rounds and full-reloaded 13 edits. This PR had no failures and no reloads.

  • tanstack.com (e80188ee7b vs npm main, 6 paired rounds):
    • Build: 18.7 → 12.7 s (−32%).
    • Peak RSS: 3,916 → 3,342 MB (−15%).
    • Dev cold → hydrated: 29.3 → 24.2 s (−17%).
    • First visit of 15 routes, and HMR: parity.
  • Corpus (064d29003a, 129 apps, medians of per-app Δ): build −12.1%, dev ready −18.8%, first render −10.5%, peak RSS −1.7%.
  • 13 large apps: builds are 2–26% faster in 9 and at parity in 3. kroma (barrel-heavy) builds 2.6% faster (774eba05a4).
  • Losses:
    • Build peak RSS: solid-router large +2.5% (+30 MB, e80188ee7b); kroma +3.8% (+64 MB, 774eba05a4).
    • Rsbuild minimal client: +1 / +3 B gzip (raw −22 B; accepted).
    • Server bytes (b25984c166): +308 B raw on Vite *.full, +696 B raw on react-start.rsbuild.full.
    • CodSpeed: +0.8–2.5 KB on 3 memory benchmarks (e80188ee7b).
All 11 cells, HMR, Vue, memory, bytes

Medium = 333 route files. Δ CIs are 95% t intervals of the paired log ratios.

cell build dev listen dev cold → hydrated compile-all dev heap after GC build peak RSS
react-start Vite medium 6,440 → 1,599 613 → 210 6,096 → 2,557 7,333 → 2,066 212 → 89.5 1,226 → 947
react-start Vite large 30,418 → 9,016 1,514 → 311 24,801 → 7,401 31,105 → 8,583 704 → 207 2,570 → 1,468
react-start Rsbuild medium ² 6,728 → 1,942 208 → 158 7,856 → 2,111 – 218 → 84.2 1,063 → 871
react-start Rsbuild large ² 27,484 → 6,143 210 → 159 32,702 → 6,501 – 733 → 207 2,819 → 2,081
react-start-deps Vite medium ¹ 6,988 → 1,846 622 → 212 6,709 → 3,126 8,050 → 2,439 236 → 107 1,240 → 1,132
react-router Vite medium 2,742 → 568 585 → 178 3,348 → 1,024 6,525 → 1,282 64.0 → 47.2 751 → 569
react-router Vite large 11,208 → 3,058 1,381 → 278 11,647 → 2,989 26,984 → 5,026 113 → 89.7 1,283 → 1,041
solid-start Vite medium 9,472 → 4,408 623 → 266 7,371 → 3,226 10,719 → 4,561 191 → 92.3 1,334 → 1,121
solid-start Vite large 42,999 → 20,019 1,484 → 357 30,690 → 10,299 43,902 → 19,417 618 → 205 2,799 → 1,975
solid-router Vite medium 4,310 → 1,932 590 → 241 3,814 → 1,543 10,076 → 4,153 63.1 → 54.0 802 → 762
solid-router Vite large 17,167 → 8,025 ² 1,412 → 332 14,349 → 4,354 40,273 → 15,426 107 → 89.7 1,211 → 1,241 ²

¹ react-start plus a generated dependency graph and tanstack.com's manualChunks. Dev SSR fails on both sides here because of a ?url wasm asset in the harness, so <Hydrate> HMR is not measured.

  • Speed: every cell is significantly faster. Build −53…−79%, listen −24…−80%, cold start −53…−80%, compile-all −56…−81%.
  • HMR (p50): route component −15…−98%, loader −9…−55%, server fn −39…−79%. <Hydrate> is −8…−46% on Vite and −30 / −38% on Rsbuild ². Shared-module edits show no significant slowdown.
  • Memory: heap after GC is lower everywhere (Solid Router compile-all n.s.). Peak RSS is lower except for the losses above. 100 edit + revert cycles show no growth (eedc638e42).
  • 500-cycle soak (18ef570edc, KB/cycle, PR vs main): react-start Vite −0.7 vs 11.2, react-router Vite 2.3 vs 6.3, react-start Rsbuild 20.9. The soak found five records that grew with deleted files; all are fixed and tested. The remaining growth is upstream (vite#23907, rsbuild#8736).
  • Start: import protection keeps a parsed AST per module in dev (hundreds of MB in large apps) #8614: import protection in dev adds +2.4 MB of heap, down from +321 MB.
  • [Start] vite build keeps every environment's server-fn compiler alive until the build ends (memory stacks across client → ssr → server) #8794: in Start + Nitro builds, heap is 64.3 MB here, vs 506.5 MB on main and 98.7 MB on main with the issue's fix (fdc48254c8).
  • Vue (1 paired round, medium; Vue Start / Vue Router): build 8,665 → 3,917 / 4,162 → 1,588 ms, cold start 10,692 → 3,073 / 5,017 → 1,349 ms. Every HMR edit applies in place, whereas main full-reloads Vue Router route edits.
  • Other builds (eedc638e42): webpack build −38%, RSC build −74%.
  • Generator without Prettier (498ac9be17): 715 → 94 ms. The react-start build floor drops from 1,629 to 779 ms.
  • Client bytes (18 scenarios, b25984c166):
    • 12 are byte-identical.
    • *.full scenarios are −35…−47 B gzip, thanks to shorter server-fn ids.
    • Rsbuild minimal is larger because Rspack renumbers module ids once our loader request changes.
    • react-start.full brotli +92 B is q11 noise.
  • Server bytes: Vite scenarios are −12…−419 B raw, except lazy handlers, which add +308 raw / ~+200 gzip per *.full scenario. react-start.rsbuild.full is +696 raw / +326 gzip:
    • +432 because the resolver imports the defining module, which makes __root a second concatenation root;
    • +264 from lazy handlers.
  • SSR cold start (a server fn imports a 1.5 MB SDK that GET / never calls; main / eager handlers / this PR): Vite ~88 / ~145 / ~88 ms, Rsbuild ~57 / ~122 / ~57 ms.

Testing

The oracle (packages/compiler-test-utils/oracle) drives real bundlers, dev servers and browsers through public APIs only. Main's bugs are pinned as .fails.

baseline (main's compilers, 80d1890bed) this branch
tests 3,594 4,247
known-bug pins (.fails) 874 45
non-pin failures 0 0
  • Oracle at b25984c166: all 8 unit and integration targets pass (4,247/4,247). With COMPILER_TESTS_PINS=run, each pin fails for its documented reason.
  • CI at e80188ee7b: the Test job passes 881/881 Nx tasks. Compiler Platforms passes on all platforms except Linux arm64, which failed one router-utils unit test, pipeline.test.ts (f), an in-flight dependency-only edit.

Beyond the oracle:

  • Real-app corpus: 190 public repos, each built and dev-tested with npm main and with this PR, in a network-restricted sandbox.
  • Leak check: a canary in the server-only code of 124 in-repo Start apps.
  • Differential fuzzers for compiler output (run against the untransformed source), the route generator, and HMR edit sequences on 5 frameworks.
  • Internal verifier, on in all tests.
  • Coverage-targeted tests.
  • Three independent coherence reviews, each followed by a consolidation pass. Each pass kept client bytes identical and compile time at parity.

Every PR bug these found is fixed, with a regression test. The fuzz differences that remain are pre-existing on main or upstream.

Results

Corpus (b25984c166 vs npm main):

  • Builds: 189/190 build on both. The 1 PR-only failure was a type-only import re-emitted as a value import, fixed in 390ca815c3.
  • Bytes: client JS 405.01 → 404.72 MB, initial JS 101.42 → 96.92 MB.
  • Leaks: 0 server-only canaries in client JS on either side.
  • Dev: 158 equal, 18 PR better, 4 PR worse, 6 mixed, 4 both fail.
    • The 4 worse: the type import, a global-boundary reload (main's behaviour), a pre-existing Vite optimizer race, and noise.
  • Route-edit HMR median: 159 → 105 ms (71 pairs).
  • Bugs it found, all fixed:
    • effectful declarations only split code reads stayed eager (+110 KB initial);
    • extracted-export TDZ cycle;
    • server-only deps kept by function-local chains;
    • lost ASI before (/[;
    • handlers' server-only imports loaded at boot (500s; fixed by lazy handlers);
    • Solid Start fix edit not shown after an error.
  • Main sometimes emits fewer chunks; that happens only with two @babel/traverse copies, which break main's DCE.

Leak check (139 builds per side, 5ff36a1816): 0 leaks here. Main leaks in mock mode; that case is pinned.

Fuzzers:

  • Compiler (110,000 cases):
    • 0 env-separation differences.
    • 0 PR reference errors, wrong values or duplicated effects.
    • 1 TDZ fixed; unread imports are now kept, as on main.
  • Generator (51,000 trees, each generated by main and four times by the PR):
    • 0 crashes or nondeterministic outputs.
    • 2 cases fixed.
  • HMR (1,040 sequences):

Coverage: lines 91.1 → 91.5%, branch arms 79.2 → 80.2%. No bug found; 2 unused paths deleted.

Vite 6 / 7 (5ff36a1816): the 13 router-plugin e2e projects pass on Vite 6.0.15, 6.4.5 and 7.3.7. Split Solid and Vue routes update in place, where main full-reloads.

Remaining fail pins (45)

reason # expected behaviour still missing
Kept main's behaviour on purpose 20 Route CSS applies only once the route matches. This needs a per-route asset loader (follow-up).
16 A root route's error/not-found component loads lazily. Root routes are never split: splitting cost SSR CPU on error renders, and the chunk was preloaded anyway.
Bundler limitation 3 On rspack/webpack/rsbuild without sideEffects, an exported component another route's chunk imports loads with that chunk.
2 On Rsbuild dev, a custom module type (.page as javascript/auto) importing a denied file is reported. Rspack rules select by resource.
1 On Rsbuild build, an unused exported component with a boundary emits no async chunk.
Upstream 1 On Rsbuild dev, adding a route's first server fn shows on the open page (rspack#16288).
Not supported yet 1 On Vite dev, a Start library's undeclared CommonJS dependency is pre-bundled.
1 In Vite bundledDev, a factory that becomes a server-fn builder keeps its handler. This needs the full-bundle API.

Behaviour changes

  • routeTree.gen.ts formatting changes once. The file is printed in your quoteStyle/semicolons. enableRouteTreeFormatting now defaults to false (true uses Prettier, loaded lazily). Custom templates are inserted as written.
  • Server-only server fns: a server fn that only server code calls, or that is only returned as data, is not HTTP-callable in production. Dev still answers it, with a warning.
  • serverFns.generateFunctionId receives the stable key (greetUser) as functionName, and default ids are shorter.
  • Server-fn handler modules run once, on the first call. A handler stays eager if it writes module state or its module uses top-level await.
  • Exported page components are split (?tsr-split=export~Name), together with their augmentations. On one corpus app: −179 KB initial, +2.4 KB total.
  • Imports read only by moved code move with that code. Unused imports stay, as on main.
  • Stays unsplit, with a warning: route callbacks that read this (Handle route callbacks that reference this during code splitting #8688), and splits that never settle.
  • Route HMR after a render error reloads the page in dev, as on main. This applies to any boundary on Solid/Vue, and to the global boundary on React.
  • Import protection:
    • per-edge mocks export the target's names;
    • build verdicts come after tree-shaking;
    • onViolation returning false gets the real module;
    • traces follow the bundler graph.
  • Route-group layout types match the runtime fullPath: '/', not ''.
  • Rsbuild:
  • Solid Start server envs split like the client. Vue Start dev no longer reloads after dependency optimization.
  • Requirements:
    • router-plugin needs Vite ≥ 6;
    • a CommonJS webpack/rspack config needs Node 20.19+ or 22.12+;
    • esbuild + autoCodeSplitting: true throws;
    • @babel/core is no longer transitive, so declare it if a plugin needs it (e.g. React Compiler).
  • Removed:
    • Babel-typed experimental hooks: codeSplittingOptions.compilerPlugins, Start compilerTransforms, serverFnProviderModuleDirectives and StartCompilerPlugin;
    • react-start-rsc/plugin/rscCssTransform, replaced by rsc: { loadCssExpression, providerDirective };
    • router-utils' Babel helpers (router-utils gains /compiler);
    • router-generator's format, inferFullPath, checkRouteFullPathUniqueness and writeIfDifferent. onRouteTreeChanged now receives { model }.
  • New dev-only subpaths: @tanstack/router-core/hmr and @tanstack/{react,solid,vue}-router/hmr.

16 changesets. Minor bumps: router-utils, router-generator, router-plugin, router-core, start-plugin-core and react-start-rsc. Everything else gets a patch.

Follow-ups

Upstream issues filed

✅ fixed · ✖ closed without a fix · ○ open

Issues

Other issues:

Supersedes #8805, #8504, #8630, #8721, #8780, #8773, #8756, #8755, #8732, #8686, #8682, #8681, #8061, #8541, #8493, #8481, #8459, #8455, #7892, #8515, #7281, #8269, #8628, #8797, #8662, #8296, #8215, #8454, #8276, #8675, #8540, #8523, #8524, #8680, #8684, #8642, #8749, #8801, #7338, #8656, #8798, #8818

Implementation obsolete (still needs a re-port after merge): #8804, #7693, #7766, #7872, #8291, #8293, #8277, #8799, #8800, #6748, #8256, #8057, #7925

How to review

The diff is +164k/−75k lines in 2,000 files. The compiler-tests/baseline branch accounts for +102k/−42k of it, and most of the rest is tests and regenerated route trees. Product code (packages/*/src) is +31k/−23k in 211 files.

  1. Core: start with router-utils/src/compiler/README.md, then read identity → source/analysis → units/mutation → rewrite → reach → plan → emit → values/summary/facts → cache/pipeline → verify. Keep tests/helpers.ts and tests/placement.test.ts open alongside.
  2. Planners:
    • Router: router-plugin/ARCHITECTURE-CODE-SPLITTING.md, then src/planners/.
    • Start: start-plugin-core/src/planners/ (start-env, hydrate, rsc-css, edges), with src/compiler/, src/server-fns/ and src/import-protection/.
  3. Hosts:
    • router-plugin/src/hosts/.
    • Start's src/vite/compiler-plugin.ts.
    • Start's src/rsbuild/ (read COMPILER_ARCHITECTURE.md first).
  4. Generator and runtime:
    • router-generator (AGENTS.md, analyze/, model/, print/).
    • router-core/src/hmr.ts and {react,solid,vue}-router/src/hmr.ts.
    • start-client-core/src/createServerFn.ts.
  5. Tests: the oracle's README.md, SPEC.md and KNOWN-BUGS.md.

To reproduce the measurements, see:

  • packages/compiler-test-utils/oracle/README.md
  • benchmarks/compiler-perf/README.md (run.mjs, compare.mjs, soak-analyze.mjs)
  • pnpm benchmark:bundle-size:run
  • scripts/benchmarks/bundle-size/server.mjs

This PR was written with AI assistance: Claude Code agents working under a human architect. Their work was checked against the oracle, e2e, perf and bytes gates above.

🤖 Generated with Claude Code

Sheraff and others added 25 commits October 25, 2026 18:15
The dev refresh marker (`page.$$tsrRefreshId = …`) was written onto the
export of another module when a split option named an imported binding
(or a member of one): a frozen component threw "object is not
extensible". React Refresh registers a component where it is declared;
the marker is only written on this module's own declarations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- dev `log: 'always'`: a compile the plan cache answered reported its
  violations twice and asked `onViolation` again (`afterCompile` already
  reports every compile; the extra `reportAgain` is gone).
- `needAdditionalPass` is a bail hook: answering `false` silenced later
  taps asking for a pass; the tap now answers `undefined` when it has no
  vote.
- the warning ledger resets on `watchRun` too, so a module a dev
  compilation rebuilds reports its compiler warnings again.
- modules kept across a compilation report again under their canonical
  resource (the RSC proxy query is dropped), the identity their edges
  were registered under.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ck soak pages; router-utils import order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `require()` edge carried no names, so import protection's mock of a
denied CommonJS or unknown target exported `default` only and
`const { secret } = require('./x.server')` read `undefined` instead of
the safe mock. The analysis records the keys the importer destructures,
the static member reads of the binding holding the result or of the call,
and `'*'` when the result escapes (`[]` for `import()`); the edge records
of both tiers carry them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ute module (#8688)

The router calls a method or function-expression callback as a method of
the options object; a split chunk's `lazyFn` wrapper calls the chunk's
export detached, changing its `this`. Such a callback stays in the route
module with a `not-split:this-reference` warning naming the file (J2.8:
informed, never broken); `this` of nested functions and classes, and arrow
functions (the module's `this` wherever they evaluate), split as before.
Also enables route HMR in the frozen-import regression test, where the
development marker is written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nner

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
In dev, an HTTP call by id may run any server function: dev compiles
lazily, so it cannot know what the client references, and a page left
open across a restart must keep working (J4.4). The refusal of server
fns no client code references applies to production builds only.

SPEC J4.2 and G13 say so; the Vite dev RSC pin that asserted the
refusal (and its now unused page helper) is removed, and KNOWN-BUGS
lists the production pin only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…duction builds with a separate provider (R44)

With RSC the provider environment builds before the client (plugin-rsc:
rsc, client, ssr; Rsbuild: interleaved by the RSC coordinator), so its
manifest was generated before the client's references were known and
every registered server function was HTTP-callable in production (J4.2).
The manifest must still list every function: server-origin calls (the
SSR RPC stubs, a handler calling another function) resolve through it. The
gate is therefore by call origin, decided where the client build's
references are known:

- Vite: the caller (ssr) environment builds last; its resolver module is
  now a gate over the set of client-referenced ids in front of its own
  forward to the provider (served by a second virtual module).
- Rsbuild: the manifest carries a placeholder literal for the set, which
  the server asset pass that already fills the Start manifest (after the
  client build exists) replaces with the client-referenced ids.
- Rsbuild RSC also never listed a function reached only through a provider
  module (the resolver's rebuild is what builds those modules, after the
  manifest was generated): the resolver is now regenerated and rebuilt
  while its content changes, bounded.

Tests: react-start-rsc `rsc-start-build` and `rsc-start-rsbuild-build` "a
server function only server code calls is not callable from the browser"
(the real request envelope sent to the private function's URL is refused,
to the exposed one answered; the handler's own call of it works).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…may answer any server fn

Takes the oracle's deletion of react-start-rsc rsc-start-private.test.ts
(conflicted with our comment update on its pin) and the rsc-start-page.ts
trim; nothing else imports the removed `page` helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… plan's violations once

The compile that registers a plan's violations prints them; a compile the
plan cache answered again (an importer Rspack rebuilt for a dependency's
edit) still asks `onViolation`, as every compile does under `always`, but
no longer prints them a second time. The warning ledger keeps its
once-per-run contract (the oracle pins "warns once per file" across dev
rebuilds), so the `watchRun` reset is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n compiler stats

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss and perf fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lation's files only

Rspack reports a deleted file only while a module depends on it, so a module
the app stopped importing and then deleted got no unlink: its plans, raw
facts, import-protection records and per-resource fingerprints stayed for the
session (soak: plans +20, fingerprints +100 per 100 cycles). After each dev
compilation the session retains what the environment's fileDependencies
still contain (RawFactsCache.retain, ImportProtection.retain).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… growth

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A known package resolves to its own specifier (D4), and dependencyPaths
turned it into a file for the host's addDependency: Rspack watched
'@tanstack/react-start' as a missing path, reported it removed, and
rebuilt every environment once after the first watch-mode build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nded its compiles on

Rspack's MultiCompiler runs a compiler again when a dependency was
invalidated while it compiled (running-outdated), from nodeDone on the
next tick after its done hooks; the MultiCompiler's done (onAfterBuild)
fires before that. The tracker settled the edit and closed the watcher
in between, so the SSR bundle kept pre-edit code (start-compiler-build-
inputs 'rsbuild build --watch > an edit of an aliased env-only
factory...'). A compiler now counts as compiling until a macrotask after
its afterDone, by which a re-run has announced itself (invalid).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ause fix

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

# Conflicts:
#	packages/router-utils/tests/pipeline.test.ts
Replace the 15 lane changesets with six: compiler core (router-utils),
route generator, router plugin, route HMR runtime (framework routers),
Start, and React Start RSC. Adds what lanes C2/C3a left out: dev-only
/hmr subpaths, production HTTP gating of server-only server fns with
the dev warning, #8688 and #7943.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- esbuild: autoCodeSplitting is refused; the esbuild installation docs
  and quickstart examples no longer enable it
- automatic code splitting: callbacks reading this stay unsplit; route
  CSS moves with the split components only when all are split
- Start: server functions only server code calls are not HTTP-callable
  in production (dev answers and warns); generateFunctionId arguments
  and the default ids; onViolation timing in builds; route CSS loading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for the compiler core

- router-utils/src/compiler/README.md: the model (one analysis per env and
  module, rewrite tree, reachability = splitting = DCE by provenance,
  OutputBuilder emit with source maps, summaries and values), the
  pipeline phases, plan cache + dependency ledger + invalidate(), hosts,
  where each planner lives, adding a planner, tests and perf harnesses
- router-plugin/ARCHITECTURE-CODE-SPLITTING.md: replaces the Babel-era
  document with the planners' outputs, rules and route HMR
- AGENTS.md pointers (packages, router-utils, router-plugin,
  start-plugin-core) and the Rsbuild architecture links; drops references
  to handleRouteUpdate and to deleted INTERNALS.md files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es and knob runs; document soak and compiler knobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erprints

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@changeset-bot

changeset-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78db3da

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 24 packages
Name Type
@tanstack/router-utils Minor
@tanstack/router-plugin Minor
@tanstack/start-plugin-core Minor
@tanstack/vue-start Patch
@tanstack/react-start-rsc Minor
@tanstack/router-generator Minor
@tanstack/router-cli Patch
@tanstack/router-core Minor
@tanstack/react-router Patch
@tanstack/solid-router Patch
@tanstack/vue-router Patch
@tanstack/start-client-core Patch
@tanstack/react-start Patch
@tanstack/solid-start Patch
@tanstack/router-vite-plugin Patch
@tanstack/react-start-client Patch
@tanstack/react-start-server Patch
@tanstack/solid-start-client Patch
@tanstack/solid-start-server Patch
@tanstack/start-server-core Patch
@tanstack/start-storage-context Patch
@tanstack/vue-start-client Patch
@tanstack/vue-start-server Patch
@tanstack/start-static-server-functions Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sheraff and others added 3 commits October 25, 2026 21:20
Remove the unused logDiff (with ansis and diff) and createIdentifier/decodeIdentifier
exports from router-utils, and router-plugin's redundant chokidar dependency: the
watcher lives in router-generator, which declares it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
revision/digestOf (plan freshness, digests, edge keys), the default server function
id and the Hydrate file hash use crypto.hash instead of createHash().update().digest().
Same bytes; available since Node 20.12 (engines: >=20.19).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…remapping and lightningcss on first use

jiti is only needed for virtual route configs, @jridgewell/remapping only when a
Rspack/webpack loader receives another loader's input map, and lightningcss only
for inline CSS with rewritable urls: none of them loads at plugin startup anymore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheraff and others added 30 commits October 28, 2026 18:40
…y binding and through re-exports (R79)

`RecognizerTable.pureCalls` lists exports of known packages whose call is
pure when its arguments are. Tier 0 recognises imports of them by binding
(renamed, or a namespace member) before any statement is classified; phase
A follows an import of a project module the module calls at its
evaluation through re-exports over summaries (a package is never read: a
third party's purity is never assumed). Known packages' summaries flag
those exports `noSideEffects` (no longer the factories', which nothing
read) and export resolution carries the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… effect in route modules (R79)

One table (router planner, `routerPureCalls`), per framework package:
getRouteApi, linkOptions, createLink, createRouteMask, lazyRouteComponent,
lazyFn, retainSearchParams, stripSearchParams, parseSearchWith,
stringifySearchWith, createRouterConfig (React, Vue). Read in every
framework: each constructs and returns a value, reads no global state,
registers nothing and never throws. Excluded: redirect/notFound (mutate
their argument, throw on request), createSerializationAdapter (throws on
an invalid key in development), the route factories (route units).

The router plugin's session knows the router packages by specifier, and
its hosts pass the bundler's resolver (Vite plugin context / dev
environment container, webpack/Rspack loader `getResolve`), so an app
module re-exporting an API is followed. Start's recognizer reuses the
table.

Tests: tamery's shape (an effect, `const { useParams } = getRouteApi(…)`,
an inline `validateSearch` call that stays) loads the API with its chunk
and warns only of the effect, directly, renamed and through an app
re-export; an impure argument keeps the call an effect; an API the route
module keeps no longer pins an earlier effect; `Route.useParams()` in the
split component unchanged. Vite, Rspack, webpack, Rsbuild; Start Vite and
Rsbuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… raw-facts build-time changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…raw facts; dependencies opaque to summaries; no parentOf on hot paths): t3/reduce-compiler-allocations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re APIs by binding only; app re-exports are not followed

Maintainer decision: drop the phase-A re-export follower and everything it
needed: `seedPureImports`, the `noSideEffects` flag on known facts' pure
exports and on export resolution, the router plugin hosts' resolvers
(Vite plugin context / dev environment container, webpack and Rspack
loader `getResolve`): the router plugin resolves nothing again. Direct
imports from the router packages (renamed, namespace members) stay pure
callees in Tier 0. Tests of the removed behaviour deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the file set is unchanged

The value analysis of every module that imports from a barrel follows the
barrel's export-star chain, and nothing shared the resolver's answers across
compiles: a monorepo importing a workspace UI kit from ~1,100 modules made
58k host resolves for 13k unique (env, importer, specifier) triples, +9% build
wall and +13% peak RSS vs main. The environment's PlanCache now memoises the
resolver's answers, importer → specifier → canonical id (the pending answer
while it settles, so concurrent compiles share one call), for one
fileSetVersion: the same authority that already re-validates the plans'
recorded resolutions, dropped as one when a file appears or disappears and
released with the environment's plans. Unresolved pairs are not kept (the
file may be about to appear; the add event re-resolves them). The keys and
ids are strings the compiles already hold, so the memo costs its Map entries
only. In dev, each resolution still yields one event-loop turn, so a dev
server's pending requests get in between an update's resolutions as they did
while every resolution was resolver I/O. Host resolves equal the unique
resolved triples; the app builds at parity with main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…solution): t3/share-analysis-resolutions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on view, nested server-fn order, opaque digests

- `#__NO_SIDE_EFFECTS__` on a callee the module declares counts whatever the
  order of declaration and call (function declarations are hoisted) and in
  Tier 0 too: one seeding (`seedNoSideEffectDeclarations`) before any statement
  is classified; units.ts no longer marks callees as it goes.
- `codeSplittingOptions.defaultBehavior: []` splits nothing unless
  `splitBehavior` or the route says so, as on main.
- The mutation model takes the output's liveness filter (R1): a write inside
  code the environment erases imposes nothing; reach.ts and the route planner
  read the env's live view (no spurious `eager-export:reassigned` on the client
  for a server-only write).
- Server-fn sites are planned in source order, so a nested site inside a
  stubbed handler is dead there whichever import binding declares it.
- An opaque package's facts keep a null summary after `getTarget` (its digest
  never changes); `getTarget` never answers a read in flight.
- The fallback-rounds bound is the region count after the first round (regions
  accumulate across rounds; the guard could not fire before).
- Prose: README, eager-exports.ts and the warning state one rule for what
  chunks copy and what they import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nked and un-planned route files, shared tsconfig reader

- Start Vite bundled dev: `watchChange` runs in one environment and applies the
  one invalidation to every environment's module graph (no per-env eviction
  that left the other environments' dependents stale).
- Start Rsbuild: the session dedupes a watch cycle's file events across the
  environment compilers (path, kind, mtime, size).
- Registry changes: `changedRoutePaths` lists the real path of a symlinked
  route file too; the webpack-family hosts (router Rspack/webpack, Start
  Rsbuild) tell the watching compiler the files changed (`invalidateFiles`),
  so modules whose rules or plan changed are built again; the Vite router host
  hard-invalidates the changed file's own modules (a file that was no route has
  no plan to evict). Start's Rsbuild generator reads the registry through the
  shared `RouteRegistryView`.
- Both Start hosts read `verbatimModuleSyntax` through the tsconfig `extends`
  chain (one reader, `src/tsconfig.ts`).
- Start's Rsbuild `finishMake` rebuilds (client tree, import-protection
  re-judge) share one helper (`rebuildModules`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion, align prose

- The eight `.fails` pins asserting that a split chunk renders the instance
  importers get are deleted: the chunk copies a pure exported binding by rule
  (R77, "Fix: none planned"); the rendering rows next to them stay, and the
  `%s` errorComponent rows assert rendering and "split or reported" only.
- Warning assertions in the four new files check position and count, not
  wording (harness rule); parse warnings are deduped like every other warning
  (code and wording).
- Prose aligned with the code: KNOWN-BUGS (R77 entry), planners/hmr.ts,
  core/config.ts `@default`, split.ts refresh identity, diagnostics.ts,
  server-fns/ids.ts rank under RSC, generator comments, router-core hmr.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… compile

Dead code (audit §3 and a symbol-occurrence sweep): RewriteSet.claimLive,
DependencyLedger.paths, WarningLedger.reset(path), unread fields of the
split decisions and route recognition, the HMR planners' second rename
table (PlanContext.nameOf replaces it), CompileRequest.env.routerSplit,
the `-start` alternative of ROUTER_PACKAGE_RE, the duplicated HmrStyle,
import-protection's old ImportGraph/buildTrace and unread members, Rsbuild
R72 remnants and copied rebuild/route-digest helpers, unused Queue and
generator helpers, FunctionIdAllocator.has/get, ServerFnRegistry.tokenOf,
StartEnvFacts.useClient, the duplicated provider self-accept unit.

Less work per compile: the route-registry fingerprint is the route id
(three canonicalJson+sha1 per compile gone); edge keys are a plain join
(sha1 only for edge-module ids); the specifier segment of a substituted
edge is cut at substitution instead of per edge per output at freeze
(markSegment and the Tier 0 marker deleted); the units of each import are
listed once per plan instead of filtered per import per output; the
dependency paths of a compile are computed on demand (only the Rspack and
webpack hosts read them).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bare import or a re-export evaluates before the module body wherever it
is written: `effectStays` no longer counts one after an effectful cluster a
chunk needs (it moved only when nothing followed it) or before an augmented
export (it stayed, `eager-split:order` / kept). Two placement tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es; delete the tsconfig reader

R80. Whether TypeScript erases a value import whose bindings are unused or
only used as types is the bundler's transform's decision (oxc reads the
nearest tsconfig through references/extends unless its options override
it; SWC never reads tsconfig). The root tsconfig reader guessed it, and was
wrong for the stock Vite template (solution-style root: the real server
module was bundled unreported) and for Rsbuild (false reports, or a leak
under `tools.swc` verbatim).

The rule verdict and the substitution stay in the pre-TS plan. In dev such
an edge is substituted in both behaviours and recorded pending; it is
decided (`onViolation`, report, or the error) when the bundler requests its
edge module, which only post-TS code does: Vite's edge `resolveId` (import
analysis, Rolldown), Rsbuild's edge-module rule loader. A veto serves the
allowed form; an `error` violation fails every request. Build is unchanged
(the R30 witness: an erased edge module is absent from the bundle).

Tests: v2-witness (the five tsconfig layouts x Vite/Rsbuild x build/dev,
the oxc/SWC option rows, error on every request, dev vetoes). Deleted: the
planner's tsconfig rows and the v2-type-only verbatim-through-extends rows
(the Rsbuild dev row pinned the over-report; the Vite rows duplicate the
v2-witness extends layout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the persistent cache again

An erasable edge is decided when Rspack builds its edge module. A dev
server started on its persistent cache restores that module without a
build, so the violation went unreported in the new session (and a previous
session's veto decided its code). At `finishMake`, after the restored
importers are compiled again, an edge module whose pending violations this
session never confirmed is rebuilt: the edge-module loader decides them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ule code on demand

Locating a violation asked Vite for the combined input map; in a pre
transform with an empty chain Vite builds an hires identity map over the
whole input (~70 us per module, 85 % of the cost of an erasable violation
nothing requests). The input is compared with the file as written first: a
match has no map to read. Edge-module code is computed only when a listener
takes it (Rspack's virtual files); Vite builds it at `load`.

IP-heavy fixture (48 erasable denied imports), paired 10 rounds: TS-erased
dev +0.6 +/- 1.7 %, build +0.7 +/- 3.9 % vs 6aee390 (was +1.9 % dev).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ler keeps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dependents-index row per path per compile

DependentsIndex.add is only ever called for in-flight rows: drop the parameter and
its dead branch. The ledger adds a path's row once, not once per fingerprint of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
guardStatementStart took the '/' of a generated '/* @__PURE__ */' annotation for a
token that could continue the previous statement, so every lazyRouteComponent
replacement walked the AST from the root to find whether it starts a statement.
It now reads the first token past leading comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A barrel's re-exports were deduplicated with Array.includes per specifier
(quadratic: 26 ms for a 2,000-module barrel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… edit

Like the split and Hydrate planners: without an edit or claim of its own the
environment planner erased nothing, so no declaration became orphaned by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ON copy

Every freeze round-tripped the planner facts through JSON to keep cached plans to
plain data. Planners build fresh facts per plan; the verify pass
(TSR_COMPILER_VERIFY, on in every test run) now fails a compile whose facts are
not JSON data, so the rule is checked where it is broken instead of paid for in
every compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver envs only

Covers the order the coherence review suspected (the nested chain's import
binding declared first, so its site is enumerated before the outer one): the
client stubs the outer handler and registers the outer fn only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…renders in its owner

The env render tree decided claim liveness with the core's statement-level
DCE edits included. When placement drops the statement of a function that
holds a hoisted nested server fn (a route component moved to its chunk, or
dead code that would pull a chunk's dependency), the handler's claim died
with it while the hoisted chain still rendered: the SSR route module shipped
the raw handler (`.handler(rawFn)`, its reads unbound, the lazy loader
unused), and every call failed. Reach decided liveness on the planner tree,
so the handler module existed too. Claim liveness is now decided by planner
edits only.

The verifier gains check (g): no output reads a module binding of the
source it neither declares nor imports (it fired on this case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tput in verify (d)

The verifier skipped `custom` outputs like a copying provider output when
counting effects, but the in-place provider's lazy handler module imports
from the env module: an effectful cluster only a handler reads moves there
(R29) and was reported as "emitted 0 times".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every output of a file carried the whole file text in its map, and
Rspack/webpack hash a module's map with its code: an edit anywhere in a
file hot-updated every output of it. A `<Hydrate>`-content edit on Rsbuild
re-emitted the unchanged route module and split chunks, whose route HMR
refreshed the route (loaders, server fns): +250 ms per edit on the
react-start medium app vs main. Main's Babel maps had no sourcesContent
either; Vite's dev server fills it in from `sources`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…revision

Rspack/webpack hot-update a module only when its output changed. A
handler-only edit left the client stub `createClientRpc(id)` unchanged, so
the route kept its loader data from the old handler (Rsbuild dev-server
test "the next call runs the edited handler"); it passed before only
because every output's map held the whole file. In dev the stub now also
names its file's revision (an argument the runtime ignores): the client
module of a file declaring server fns changes with every edit of the file,
as Vite updates every module of the file; other outputs stay byte-stable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing it

6e14f86 dropped markSegment, which also skipped an edge whose specifier
no slice of the output held. Dynamic edges had no other check, so every
output recorded every `import()` of the file: a route's server module
judged the `lazy(() => import('./X.client'))` its split component holds
(or no output holds: `ssr: false`), and in dev `behavior: 'error'` failed
that route module, so every page of the app failed. The edge is recorded
again only where a slice of the output holds its specifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…their suspension, not on 5 ms

(f) asserted that the edge-policy verdict was asked 5 ms after the compile
started; on a loaded or slow runner (CI Linux arm64) phase C is not reached
within that window (the dev resolution yields add macrotask turns), so the
check saw no call. Each test now awaits the point it suspends at (the
verdict asked, the planner's prefetch, the host's resolution); the
assertions are unchanged. Reproduced 3/80 with 16 concurrent runs on two
CPUs before, 0/208 after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Projects

None yet

1 participant