Skip to content

feat: migrate Router and Start compilation to Yuku - #8504

Open
Sheraff wants to merge 162 commits into
mainfrom
codex/yuku-evaluation
Open

Sheraff wants to merge 162 commits into
mainfrom
codex/yuku-evaluation

Conversation

@Sheraff

@Sheraff Sheraff commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

🎯 Changes

Replaces Babel with Yuku for parsing, analysis, transforms and code generation in router-utils, router-generator, router-plugin, start-plugin-core and react-start-rsc. No Babel fallback, and no new caches: each transform analyzes its file once.

Only compiler packages change; the router and Start runtimes are untouched. Client bundles are unchanged in all 18 bundle-size CI scenarios. Real apps see one intended output change: exported functions and classes used by split components (see "What changes for users"). Compiler extension hooks now expose Yuku nodes, so this ships as a minor.

CI is red on purpose: 8 regular tests fail until their regressions are fixed here or in Yuku (see "Intentionally failing tests").

Performance

Workload Babel → Yuku (median) Time Peak RSS (median)
Route splitter, 52 fixtures 434 → 129 ms −70.2% 401 → 227 MiB
250-route production build 2,663 → 1,668 ms −37.4% 889 → 771 MiB
750-route production build 7,712 → 4,910 ms −36.3% 1,211 → 1,067 MiB
Start production build (e2e/react-start/server-functions) 2,072 → 1,398 ms −32.5% 917 → 822 MiB

Measured on Yuku 0.19.0, main 57bd1a92f9 vs this PR at 7a079cf71a, Linux x64 (Ryzen 7 255), Node 24.8.0. That's 10 alternating pairs, load-gated. Saving a route file in a Vite dev server takes the same time before and after the last changes (~49 ms to the update). Scripts and earlier raw results are kept out of the merge, browsable at e1da04f762.

Performance work is left for follow-up PRs. The biggest remaining cost is copying the AST for each output (cloneModuleAst, mostly structuredClone, ~38% of the splitter's time); reusing a route's analysis across its outputs, which this PR deliberately doesn't cache, is another 5–7% on large route builds. The WebAssembly fallback core is as fast as the native one here (129 vs 132 ms, +13 MiB): only parsing and analysis go through it.

What changes for users

All of this is in the changeset.

  • Compiler extensions: hooks and AST helpers take Yuku nodes, and Babel paths and helpers are gone.
  • Node.js: router-utils, router-generator, router-plugin, router-cli and router-vite-plugin need ^20.19.0 || >=22.12.0. Yuku is ESM-only, and our CommonJS builds require() it. Start packages already required >=22.12.0.
  • Exported functions and classes used by a split component (a page component it renders, a store class, a helper) move to the route's shared module, marked side-effect free: one instance for every importer, and the bundler can keep them out of startup. In React route files the shared components get a module of their own, apart from the other shared bindings, so that React Refresh can hot-update them: with one module for both, every edit of them reloaded the page in Vite bundled dev. main copied them into the chunk (two instances); this PR first imported them from the route module, which pulled them into the entry. Startup JS (gzip, entry plus static imports) against that previous behaviour: b2b-saas-starter 353 → 252 KB (main 351), tanstack-start-faster 198 → 195 KB (main 195), tanstack.com about the same (379 KB, main 376). It goes back to the previous size with webpack cached rebuilds, with an app manualChunks group that captures Start packages, or with rolldown before 1.1.5 (tanstack.com has the last two). A stateless exported function that reads Route is still copied, as on main.
  • createServerFn not assigned to a module-level variable (returned from a function, default export, object property, inside a block) is now a build error with a code frame. We built and ran each rejected placement on main, and none worked: depending on the other Start APIs in the file, main already threw "must be assigned to a variable", leaked the handler and its server-only imports to the client (where the call returned undefined), or failed later in the bundler or at runtime. as any, satisfies and parentheses are still accepted. Still not detected, as on main: a function returning an unfinished builder, defineAction(v).handler(H) (pinned in test: pin known compiler bugs as expected failures #8630).
  • Routes created inside a function or IIFE get a bundler warning and are not code-split. main split them: correct when the split options only use module-level code, otherwise a crash at render (options closing over function locals), a build error (a function-local component) or silently the wrong component (a parameter shadowing a module-level one). This PR renders all of these correctly, but regresses the shapes main split correctly: their components load with the route module, React edits full-reload on Vite (main hot-updated) and can leave a blank page on Rsbuild (when app.tsx creates the router), and Solid drops loader/beforeLoad edits. These are known regressions waiting for a decision on restoring splitting for safe shapes; they have no failing tests yet, and the existing tests only cover the warning. One app route in ~50k real-world files uses this shape.
  • Smaller output changes:
    • a class, enum, namespace or reassigned let used only by a split option moves into that chunk, like other declarations already did;
    • errorComponent: X as any with an exported X is no longer duplicated into the chunk;
    • <Hydrate children={…}> is split like JSX children;
    • .js files no longer accept TypeScript syntax;
    • a type and a value exported under one name from two export statements are rejected, as in TypeScript.

Also fixed along the way, each with a regression test that fails on main:

  • Route options: options wrapped in as any, satisfies or ! are split (main left the file untransformed, and for server routes shipped the server handlers to the client); an object-form loader (loader: { handler }) once the loader is split; the generator records method and accessor option keys.
  • Split components: call expressions containing JSX (React.memo(() => <p />), a Vue defineComponent setup) no longer fail to compile in split mode (a 500 in dev); a component read by a split and an unsplit option no longer throws "is not defined"; function Page + export { Page } used as the component no longer breaks the build; a component sharing its name with a type is no longer duplicated.
  • Chunk contents: the classic @jsx factory is no longer missing; enums and namespaces read by the loader and the component are declared once; dotted namespaces keep their bodies; value imports only read by types are no longer copied.
  • Start: createServerOnlyFn or createIsomorphicFn passed to a server fn handler; .server() and .validator() chain links in a cast; createServerFn declared inside a function.
  • Wrapped Start factory callees: (createServerOnlyFn as any)(fn), createServerOnlyFn!(fn) and the same for createIsomorphicFn are compiled. main left these calls untransformed: the client bundle shipped the server implementation and its imports and ran it in the browser (import protection only caught *.server.* modules). A wrapped createClientOnlyFn ran during SSR instead of throwing.
  • Hydrate: children reading Route, also when it is exported under a string name; children passed through a children prop or a spread; split={false} in a cast.
  • Import protection: usage positions (JSX usages found, types and member properties skipped); mock names no longer collide with the module's own declarations.
  • Route HMR on Vite: Solid routes with automatic code splitting and all Vue routes hot-update instead of reloading the page. The route HMR code called accept on an alias of import.meta.hot, and Vite only treats a module as an HMR boundary when its code calls import.meta.hot.accept( literally.

Binaries and platforms

  • Yuku ships native bindings for 12 platforms, including musl, arm and Windows arm64. Code generation is plain JavaScript.

  • router-utils falls back to Yuku's WebAssembly core when the native binding can't load: platforms without a binding, or a missing, corrupt or wrong-arch binding. It uses WebAssembly directly in WebContainers (StackBlitz, bolt.new). The output is byte-identical to the native core's, and it runs just as fast. The fallback does not work on big-endian hosts (s390x, Yuku chore(deps): bump minimist from 1.2.5 to 1.2.6 in /docs #253), under node --jitless, and probably on CPUs without Wasm SIMD (seen under qemu, not confirmed on hardware). main works there.

  • CI (#8626) runs the compiler tests and a Vite and Rsbuild build on Windows x64, macOS arm64, Linux arm64 and Linux x64 musl. All four passed at 8f02f6ac2e; now the unit tests fail there on exactly the 8 intentional failures, so the builds are skipped.

  • Checked locally, with no failure main doesn't also have: npm, pnpm 10 and 11, yarn 1, Yarn 4 including PnP, Bun, Deno, Node 20.19 and 22.12 (ESM and CommonJS), worker threads, Alpine (musl), armv7, and a simulated WebContainer. npm --omit=optional makes Yuku fall back to WebAssembly, but Vite 8 apps fail there anyway, on main too: rolldown needs its binding.

    Also tested in a real WebContainer: the tanstack.com example playground, run locally with tanstack.com#1359, with this PR's packages from pkg.pr.new. The React start-basic example works in dev (server functions, code splitting, HMR) and as a served production build; a Router-only example builds and runs too.

  • Yuku versions are pinned exactly through the pnpm catalog. pnpm 11 and yarn 4 refuse packages less than a day old by default, so a Yuku bump must be at least a day old before a release that ships it.

Validation

  • Unit tests: router-utils 91, router-generator 276 + 12, router-plugin 1,041 (6 intentional failures), start-plugin-core 1,001 (2 intentional failures), react-start-rsc 67. Types, lint and package checks pass.
  • E2E, all passing: Start basic in all 9 Vite/Rsbuild modes; HMR, server functions, RSC, import protection, deferred hydration, streaming, selective SSR and serialization adapters; Solid and Vue Start; the Router code-splitting apps on Vite, Rspack and esbuild. Full run before the campaign; after the exported-bindings change, the React and Solid code-splitting apps and Start basic (Vite SSR) were re-run.
  • Differential builds against main on 30 targets (Vite, Rsbuild, Rspack and esbuild × React, Solid and Vue, Router and Start). The output is equivalent apart from formatting, comments, import order and the changes listed above.
  • Engine-agnostic regression tests were merged into main first (#8629). Snapshots are now Prettier-formatted (#8658), so the 117 snapshot changes here are real output changes, not formatting.

A confidence campaign then compared this PR with main (57bd1a92f9):

  • Real-world files: 50,391 files from 19,884 GitHub repos, 852,207 compiler outputs compared after normalization.
  • Differential fuzzing: ~204k mutants of 15,590 seed files, through every compiler entry point.
  • Real apps: 64 apps and starters end to end (build, smoke tests, server functions, HMR). 51 build on both sides, 13 fail on both for unrelated reasons, none on one side only.
  • Source maps, CRLF, dev/HMR and platforms: the repo's examples and 19 real apps; 43,761 outputs and 18 repos with CRLF or mixed line endings; dev and HMR in 10 apps across React, Solid, Vue, Vite and Rsbuild; the installs above.
  • Mutation testing of all of compilers.ts, start-compiler/ and the Hydrate transform (plus the dead-code helpers, the import-protection rewrite and the HMR plugins). Every surviving mutant that changes behaviour got a test.
  • Ported external fixture suites: Babel, Oxc, SWC, test262 and TypeScript conformance fixtures, plus inputs from closed issues and the repo history: ~9.6k inputs.
  • Every finding became a test: tests passing on both sides go to #8721; regressions are regular failing tests here; bugs present on both sides are pinned in #8630; fixes have regular tests here.

Intentionally failing tests

Each passes on main and fails here until its regression is fixed.

Our code, to be fixed in this PR:

  • The import-protection rewrite drops a JSX pragma that follows a directive.

Yuku, waiting for upstream fixes (no workarounds here), one test per issue (see the table below): #254, #255, #256, #257, #258, #259 and #260. The two most likely to reach users: #260 (JSX text such as — b or 1st after a </> fails to parse) and #258 (Solid and Vue route files with an empty import type {} break in dev without code splitting). #253 (big-endian hosts) has no test: CI has no big-endian runner.

Related

Upstream Yuku issues

The issues we filed before the campaign are fixed, and this PR needs no workaround for any of them. The ones the campaign found are open:

Issue Fixed in
#202 Purity comments with leading whitespace dropped 0.12.0
#204 JSX references missing for _, $ and Unicode component names 0.12.0
#203 Invalid object-method AST crashes code generation 0.15.0
#229 import X = ns.Y marks ns as a type reference 0.18.0
#230 Decorators printed after export 0.18.0
#231 JSXText.value not entity-decoded 0.18.0
#232 False "Duplicate export" for a type and a value sharing a name 0.18.0
#237 Comments dropped inside an empty JSX expression container 0.18.1
#244 Empty module WebContainer bug 0.19.0
#253 AST buffer read in host byte order, so parsing fails on big-endian hosts open
#254 Source maps: a JSX attribute's string value maps to the attribute name open
#255 Parentheses dropped around a postfix ++ operand followed by a line comment open
#256 The for (init;;) no-in restriction leaks into parentheses and literals open
#257 False "Duplicate private name" for overloads of a private method open
#258 import type {} from 'x' printed as invalid import type 'x' open
#259 False "'super()' is only valid in a constructor" in a nested class field's computed key open
#260 JSX text after a closing fragment </> lexed as JavaScript open

Written with Claude Code (AI-assisted).

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Route code splitting supports more TypeScript patterns, exports, shared state, and JSX configurations. Object-form loader handlers can be split into their own chunks.
    • Hydration boundaries better preserve children and required route references across split modules.
  • Bug Fixes
    • Routes created inside functions or IIFEs trigger warnings and are not code-split.
    • Unsupported server-function declarations now fail builds consistently. Compiler errors provide clearer source locations, and CSS transforms handle more JSX and argument variations.
  • Compatibility
    • Five packages now require Node.js ^20.19.0 || >=22.12.0. Compiler extensions must migrate to the updated interfaces.

@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

The pull request replaces Babel-based parsing and AST transforms with Yuku analysis and AST utilities across router and Start packages. It updates route generation, code splitting, hydration, import protection, and RSC CSS transforms. Server-function declarations now require module-level variable assignments.

Changes: Compiler and route transformation migration

Layer / File(s) Summary
Yuku AST utilities and route generation
packages/router-utils/*, packages/router-generator/src/transform/transform.ts, packages/router-generator/tests/transform.test.ts
Router utilities add module analysis, AST cloning and generation, reference tracking, source-position helpers, and unused-binding removal. The route generator uses Yuku predicates and unwraps wrapped route expressions.
Router code splitting and HMR
packages/router-plugin/src/core/*, packages/router-plugin/tests/*, packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
Router compiler and HMR transforms use Yuku nodes and generated statements. Route analysis is cached with source validation and a 128-entry limit. Routes created inside functions produce warnings.
Start, hydration, and RSC transforms
packages/start-plugin-core/src/*, packages/start-plugin-core/tests/*, packages/react-start-rsc/src/plugin/rscCssTransform.ts, packages/react-start-rsc/tests/rscCssTransform.test.tsx
Start compiler, hydration, import protection, and RSC CSS transforms use analyzed modules and cloned ASTs. createServerFn declarations must be module-level variable initializers.
Release and package configuration
.changeset/*, packages/*/package.json, pnpm-workspace.yaml, e2e/react-start/rsbuild/tests/error-overlay.spec.ts
Changesets describe releases. Package configuration adds Yuku dependencies, removes Babel dependencies, updates Node.js engine ranges, and updates syntax-error snapshots.

Priority: ➖ Normal

Estimated code review effort: 5 (Critical) | ~90 minutes

Merge Risk: 🔵 Low · up to 1e64b

The compiler migration looks broadly sound. However, a route option that points to a global or declared identifier can produce a split chunk that fails to build. A documentation section also still names a removed API. Both fixes are small; the chunk export should be fixed before merge.

Pre-merge checks: Passed 4 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Docstring Coverage Inconclusive Docstring coverage is 14.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 90 functions across 50 files. (186 skippe… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly summarizes the primary change: migrating Router and Start compilation from Babel to Yuku.
Description check Passed The description is detailed and covers the changes, motivation, validation, known failures, release impact, changeset, testing, and AI assistance disclosure. The contributing-guide checklist item rema…
Full details: Docstring Coverage

Explanation

Docstring coverage is 14.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 90 functions across 50 files. (186 skipped: 13 unsupported, 173 over the file limit.)

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • 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.

@nx-cloud

nx-cloud Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit cd22d48

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 9m 14s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 36s View ↗

☁️ Nx Cloud last updated this comment at 2026-10-15 20:37:01 UTC

@socket-security

socket-security Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedyuku-core@​0.19.0921007091100
Added@​yuku-toolchain/​types@​0.19.01001007496100
Added@​yuku-core/​wasm@​0.19.0761007792100
Addedyuku-ast@​0.19.01001009996100
Addedyuku-analyzer@​0.19.01001009996100
Addedyuku-codegen@​0.19.01001009996100

View full report

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

7 package(s) bumped directly, 3 bumped as dependents.

🟨 Minor bumps

Package Version Reason
@tanstack/router-cli 1.167.44 → 1.168.0 Changeset
@tanstack/router-generator 1.167.44 → 1.168.0 Changeset
@tanstack/router-plugin 1.168.46 → 1.169.0 Changeset
@tanstack/router-utils 1.162.4 → 1.163.0 Changeset
@tanstack/router-vite-plugin 1.167.46 → 1.168.0 Changeset
@tanstack/start-plugin-core 1.171.53 → 1.172.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/react-start-rsc 0.1.63 → 0.1.64 Changeset
@tanstack/react-start 1.169.2 → 1.169.3 Dependent
@tanstack/solid-start 1.169.2 → 1.169.3 Dependent
@tanstack/vue-start 1.169.2 → 1.169.3 Dependent

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

This pull request does not affect bundle size in any measured scenario.

@pkg-pr-new

pkg-pr-new Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@8504

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@8504

@tanstack/eslint-plugin-start

npm i https://pkg.pr.new/@tanstack/eslint-plugin-start@8504

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@8504

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@8504

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@8504

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@8504

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@8504

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@8504

@tanstack/react-start-rsc

npm i https://pkg.pr.new/@tanstack/react-start-rsc@8504

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@8504

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@8504

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@8504

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@8504

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@8504

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@8504

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@8504

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@8504

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@8504

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@8504

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@8504

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@8504

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@8504

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@8504

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@8504

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@8504

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@8504

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@8504

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@8504

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@8504

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@8504

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@8504

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@8504

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@8504

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@8504

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@8504

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@8504

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@8504

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@8504

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@8504

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@8504

commit: cd22d48

@codspeed

codspeed Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 4.1%

⚡ 1 improved benchmark
✅ 193 untouched benchmarks
⏩ 3 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ Memory mem server aborted-requests (solid) 151.7 KB 145.7 KB +4.1%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing codex/yuku-evaluation (cd22d48) with main (6777b8b)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Sheraff commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Exploratory performance experiments

Two rounds of subagent prototypes on c32cb041a25679107a2b22454499dcf98ff9ec75, using Node 24.8.0 and Yuku 0.11.0 on an Apple M3 Max. These are directional “vibe checks,” not production-ready implementations or release validation. Timed workloads were serialized, with alternating baseline/candidate runs: generally five fresh processes in round one and three in round two, with exceptions noted in the individual reports. No production source changes were committed or pushed.

The mechanism descriptions below identify a concrete starting point and the contracts to preserve. Compiler timings are not whole-build timings; gains from separate rows must not be added together. Where a combination was measured, it is explicitly identified.

Mechanism Explanation of the mechanism Result from the subagent vibe check Assessment
Fuse AST cloning and provenance construction In packages/router-utils/src/ast.ts, replace structuredClone followed by separate original/copy traversals with one copying traversal that also constructs the original-to-copy and copy-to-original mappings required by createOutput in the Router splitter. Keep each output tree independently mutable and preserve every AST field, comments, literal values, and generated-reference provenance. Native literal values can contain RegExp, so an ordinary recursive plain-object copier is insufficient. Full splitter corpus: 95.88 → 65.60 ms (−31.6%), peak RSS 266.0 → 241.5 MiB. Start changed-module workload improved 10.0%; dense workload 13.6%. Code/map digests matched. An extra regex case caught a bug in the initial copier; the final measurements were rerun after correcting it. High priority. Strong compiler-level gain with lower allocation. Needs robust AST-value/depth handling and production validation; this result does not rely on sharing mutable nodes.
Construct compiler-owned AST directly instead of parsing snippets Replace repeated parseStatements/parseExpression calls for known generated structures with typed Yuku AST builders. Start with RPC expressions and imports, then provider wrapper declarations and their metadata. Preserve the existing linkGeneratedReference/generated-reference ownership model, directive handling, source locations, and output contracts. The second prototype broadened the first rather than introducing a parallel compiler path. Original RPC/import builders improved Start workloads ~11%. Expanded provider builders alone improved changed modules 48.90 → 38.09 ms (−22.1%) and dense modules 133.23 → 102.77 ms (−22.9%). Expanded builders + fused cloning, measured together, reached 31.49 ms (−35.6%) and 80.88 ms (−39.3%). Code/maps matched. Nonempty provider directives were not covered by the timed corpus. High priority. A concrete extension of a measured win. Combined numbers are real measurements, not summed estimates. Broader snippet sites and directive cases still need coverage.
Generate sibling route outputs while analysis is available In the route splitter plugin, treat a source route as one production compilation job: analyze once, determine referenced virtual/shared siblings, generate those siblings immediately, and retain bounded code/map artifacts for subsequent bundler requests. Release the heavy analysis and remove artifacts when consumed. The prototype used a 32 MiB artifact bound and generated only siblings referenced by the emitted reference module. Production implementation must preserve compiler hooks, output-affecting options, and source invalidation. Actual Vite builds: 250 routes 1159 → 1038 ms (−10.5%), RSS 602 → 639 MiB; 750 routes 2722 → 2612 ms (−4.0%), RSS 1126 → 1115 MiB. At 250 routes analyses fell 751 → 251. Emitted JS and actual map assets matched. Three alternating pairs; the smaller 4% gain is directional. Promising production optimization. Removes real repeated analysis, but memory does not automatically improve. Development behavior, custom hooks, and concurrent source changes remain outside the prototype.
Use a small worker pool with route-local analysis and artifacts Schedule whole route jobs across a small lazy worker pool, keeping related outputs on the same worker. Transfer source/options and return code/maps; do not transfer ASTs. Reuse source handles for unchanged inputs and keep native analysis local. Integrate asynchronous completion with the bundler so it can consume completed artifacts while other routes compile. Include worker startup, transfers, and shutdown in end-to-end measurements; preserve custom hook semantics before supporting arbitrary extensions. Actual Vite builds, baseline already using sibling batching: 250 routes 1062 → 895 ms (−15.8%), RSS 624 → 613 MiB; 750 routes 2630 → 2070 ms (−21.3%), RSS 1111 → 1155 MiB. With fused cloning already enabled at 750 routes: 2108 → 1829 ms (−13.2%), RSS 1113 → 1156 MiB. Startup/shutdown included; emitted JS/maps matched. Earlier compiler-only runs showed one worker offered no convincing benefit. Promising after batching and cheaper per-route work. Whole-build evidence now exists. The prototype excludes HMR, custom compiler hooks, and split callbacks. Its gain includes useful pipeline overlap, not just parallel CPU.
Delay mutable AST cloning until a rewrite is confirmed In StartCompiler.compile, discover and resolve candidates against the immutable analyzed source before creating the output clone/editor. Preserve semantic ingestion for later cross-module consumers even when the current module needs no rewrite. An external AST-transform plugin must still receive its expected mutable tree, so plugin execution can require cloning independently of built-in candidates. Reachable unrelated .handler(...) calls that pass the actual bundler prefilter improved 18.01 → 10.08 ms (−44.0%); an 80% no-op/20% server-function mix improved 26.56 → 18.45 ms (−30.5%). Fully transformed workloads regressed 3.8–5.1%. No-op re-export ingestion and external-plugin checks passed. Workload-dependent. Investigate actual false-positive frequency before adopting. The initial no-keyword microbenchmark was filtered out by real bundlers and was excluded from the claimed reachable gains.
Prune before copying, or selectively copy retained subtrees Use immutable source ownership/reachability to determine which top-level statements a shared output retains, then independently clone only those subtrees and build provenance for them. Start with shared-output paths where this does not change external plugin visibility. Do not share mutable source nodes merely to avoid copying; general copy-on-write would require stronger mutation ownership than the current hooks provide. Narrow prune-before-copy prototype: full splitter 64.24 → 65.50 ms, approximately 2% slower than fused full cloning, with RSS 242.6 → 238.2 MiB. An extreme large-module/tiny-shared-output case improved 4.924 → 0.313 ms, but was not representative. Corpus code/maps and source immutability checks passed. Not a default win. Potentially useful for unusually sparse retained output. General immutable sharing/copy-on-write was not implemented; the tested variant fully cloned survivors.
Use syntax-only parsing for callers that do not need bindings Identify callers whose contract consumes only syntax, then use yuku-parser instead of building an analyzer module and symbol graph. The concrete prototype targeted the Solid/Vue fallback HMR path. Preserve diagnostic behavior with semanticErrors: true; do not apply this broadly to Start resolution, import protection, or route splitting where binding identity is required. Across 54 sources, parsing 2.072 → 1.467 ms. The whole fallback append-HMR/print pipeline improved only 17.30 → 16.40 ms (~5%, noisy), with identical code/maps. Bare parsing without semantic checks accepted all three tested invalid programs rejected by the analyzer. Additional native binding cold-import cost was not measured. Limited, targeted opportunity. Avoid paying for semantic graphs where genuinely unused, but the complete-path saving was small and the diagnostic contract matters.
Reuse parameterized HMR statement templates Analyze fixed compiler-owned HMR statement templates once, clone them with generated-reference provenance, and substitute route-specific literals/identifiers. This removes repeated parsing/binding of almost identical scaffolding. Give template parameters explicit, collision-proof ownership and retain per-output mutation isolation instead of matching arbitrary user literals by value. Warm snippet construction for 50 routes 8.507 → 4.163 ms (−51.1%), RSS 139.8 → 71.0 MiB; generated JavaScript matched. This measures template construction only, excluding printing and actual HMR latency. The experimental reserved-literal parameter needs a collision-proof production design. Promising secondary optimization. A useful complement to builders for larger fixed scaffolding, but the measured percentage is not an application HMR speedup.
Carry immutable cleanup metadata across outputs In removeUnusedBindings, retain source-derived all-symbol declaration indexes, name lookups, declaration owners, and original-export sets on the immutable analyzed module, for example through a Module-keyed WeakMap. Recompute per-output liveness because it changes with edits. The existing root-only RouteModuleAnalysis.graph cannot directly substitute for this all-scope metadata. Inspection did not find generated-code reparsing to eliminate here; the concrete redundancy was index reconstruction. Source-metadata reconstruction fell 1250 → 250, with 1000 reuses. Actual Vite builds improved 0.8% at 250 routes and 3.0% at 750 routes. All 54 fixtures/543 outputs retained identical code/map digests. Valid but lower priority. Small whole-build signal, especially at 250 routes. Keep a single owner for immutable metadata and fresh output-specific liveness.
Narrow or bundle the standalone compiler import graph Provide a compiler entry that imports only the analysis/transformation dependencies it needs, avoiding unrelated utility barrels and adapter orchestration. The prototype bundled/pruned the standalone Start compiler graph; it did not optimize the entire adapter. This is particularly relevant to worker entry points, where every worker otherwise repeats cold module loading. Cold Start compiler import 18.81 → 9.85 ms; first compilation stayed around 5 ms. Bundled compiler code/maps matched for 18 server-function outputs. Full adapter import was not improved by this prototype. Useful fixed saving, especially for workers. About 9 ms absolute in this workload; keep it distinct from full-adapter startup changes.
Defer sitemap/XML dependencies until sitemap generation is needed Follow the adapters’ eager post-build → build-sitemap → xmlbuilder2 import chain and replace the sitemap dependency with an asynchronous import inside the enabled sitemap-generation path. Preserve the existing awaited post-build hook and sitemap behavior. Deferring the entire post-build module was also tried, but sitemap-only deferral captured nearly all of the benefit and is the narrower change. Uninstrumented physical-copy comparisons: full Vite adapter import 165.73 → 146.41 ms; Rsbuild 142.48 → 123.29 ms. Actual minimal Start dev readiness 249.91 → 225.75 ms; total to first rendered document 580.93 → 555.48 ms. Production smoke outputs and sitemap XML matched. Earlier loader-hook measurements overstated the gain and are not used here. Good narrow startup optimization. Approximately 19 ms import / 25 ms first-document saving on this fixture, without changing request transformation algorithms.
Overlap unavoidable initialization with independent startup work Start expensive imports or worker initialization while independent bundler setup proceeds, rather than waiting until the first transform/request. Current compiler/Yuku imports are static and happen before plugin setup, so adding a prewarm call inside setup cannot hide their cost; an asynchronous loading or worker boundary is necessary. A separate concrete prototype eagerly loaded the real SSR entry during Vite startup, which also moves server-module top-level evaluation earlier. Real SSR-entry prewarming added only about 4 ms median improvement beyond deferred post-build loading, with overlapping ranges. No convincing extra win. Compiler/native setup prewarming was not a drop-in change because of the existing static import graph. No compelling result yet. Do not add lifecycle complexity on the strength of synthetic overlap. Earlier module evaluation also has behavioral implications.
Skip source-map generation when no stage consumes it Propagate actual map requirements to generateModule and avoid constructing maps/source-content data where the bundler and downstream transforms genuinely do not need them. Preserve intermediate maps when later transforms depend on composition; a final-output maps-off setting alone is not proof that every intermediate map can be omitted. Codegen over 54 fixtures 1.627 → 1.381 ms, saving 0.246 ms; generated JavaScript matched. Low priority at this scale. Real but small absolute saving; map requirements must remain correct.
Avoid per-asset gzip size reporting in large builds Configure Rsbuild’s size reporter to emit raw sizes only, compact output, or disable reporting when appropriate, avoiding compression of every emitted asset solely for terminal statistics. This changes reporting work rather than compiler output. Preserve useful user diagnostics and avoid treating this as a Yuku-specific compiler gain. Synthetic build with 751 files / 6.0 MB: post-compiler tail 36.57 ms default → 7.61 ms raw-only → 1.75 ms off. Emitted assets matched. Existing TanStack bundle fixtures already disable this reporting. Useful for affected user builds, not an improvement to the existing measured fixtures. Raw-only retains information while eliminating most of the overhead.
Parallelize browser/server development compilation Investigate replacing broad compiler serialization with a precise metadata-completion barrier so independent work can overlap. Non-RSC Start currently orders compilers to make client/provider server-function metadata available before server resolver finalization. RSC already interleaves phases through its native coordinator. Preserve explicit generation ownership and required metadata availability; simply deleting compiler dependencies is not a valid optimization. Source inspection found an intentional semantic dependency in non-RSC and existing interleaving in RSC. No unsafe parallel toggle was benchmarked. Per-environment compiler queues are already separate. Architectural follow-up, not a quick optimization. Needs a narrower proven completion boundary before meaningful performance evaluation.
Lazy compilation with bounded prewarming Compile only the graph needed for initial rendering, then prewarm likely navigation modules with bounded concurrency, preserving synchronously required hydration modules and complete server-function discovery/resolution. Start currently explicitly sets Rsbuild dev.lazyCompilation: false; supporting partial graphs requires integration with those contracts rather than merely enabling the bundler option. Feasibility inspection only: no safe configuration-only prototype or first-navigation performance claim. Broader Start/Rsbuild project. Potentially reduces initial work, but was not demonstrated by these experiments.
Schedule optional background work after startup/compile idle Identify work that is genuinely optional for the initial graph and defer it until compilation is idle, with cancellation/rescheduling tied to the dev-server lifetime. The inspiration was React Router’s typegen scheduling, but TanStack route generation feeds subsequent compilation and cannot simply be postponed. No equivalent optional background typegen workload was found in the inspected TanStack paths; required route generation is awaited by consumers. No concrete target found. Do not add scheduling machinery or copy the other adapter’s delay without an applicable workload.
Launch resolved executables directly instead of through a package manager Where a build/dev path repeatedly invokes a package-manager launcher such as npx, resolve the installed executable and invoke it directly with the current Node binary, preserving arguments, environment, and error behavior. This avoids wrapper-process and package-resolution startup overhead, but only applies if such a subprocess exists. No matching subprocess/package-manager spawning path was found in the inspected adapters/generator. Inapplicable to the inspected paths. The upstream adapter’s optimization does not automatically transfer here.

Suggested order

  1. Fused cloning/provenance construction and expanded typed builders.
  2. Lazy sitemap dependency loading.
  3. Sibling output batching, then a small route-local worker pool.
  4. HMR templates and immutable cleanup-metadata reuse if more savings are needed.

The worker build prototype is production-only and rejects unsupported custom hooks/split callbacks/HMR. Selective copying, parser-only analysis, and delayed cloning have narrower applicability than their isolated best-case numbers suggest. No full package/e2e/bundle validation matrix was run for these experimental implementations. The existing upstream JSX-reference blocker remains unchanged. Prototypes and raw results remain in local scratch worktrees; they have not been published as implementation commits.

Sheraff commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Potential upstream Yuku API requests

Recording four requests for a future discussion with the Yuku maintainer, based on the exploratory performance experiments.

API review was against Yuku commit e9471f0. These are proposals, not existing APIs or agreed implementation plans. The illustrative names below are placeholders. Measured TanStack prototype gains support the mechanisms; they do not establish additional upstream speedups, and separate gains must not be added together.

1. Independent output trees with source-node and binding provenance

Request: provide a schema-aware cloneWithOrigins or module.fork() facility that creates an independently mutable output tree and supplies cheap originalOf(copy) / copyOf(original) mappings, including subtree copies where useful.

Current gap: module.symbolOf(copiedNode) cannot recognize an ordinary clone because lookup depends on the original decoder’s object-to-index map. TanStack therefore copies the tree and reconstructs forward/reverse correspondence through additional traversals. Yuku already owns the AST schema and decoder identities, making this a natural toolchain primitive. Current symbol lookup

Possible implementation: start with one schema-aware copying traversal that also creates provenance mappings and, if useful, parent information. A more ambitious alternative is independent decoder caches over the same immutable backing buffer, avoiding a copy of an already-materialized JavaScript tree. Preserve comments, spans, regex/BigInt literal values, and output mutation isolation. Decoder cache, internal index primitives

Evidence: our fused clone/provenance prototype reduced the full splitter workload 31.6%, with peak RSS 266.0 → 241.5 MiB. This is an existing TanStack-side optimization that an upstream primitive could make safer and easier to maintain, not a prediction of another 31.6% improvement.

Contract and scope: provenance refers to the immutable source analysis. It must not imply that arbitrary output edits automatically update semantic tables. This is the strongest near-term request; fully transparent copy-on-write is not necessary for an initial version.

2. Precompiled analyzed AST templates with typed holes and explicit binding references

Request: allow a compiler-owned fragment to be parsed/analyzed once, then instantiated with expression, statement-list, literal, and binding-reference parameters, without reparsing/reanalyzing every instance.

Current gap: typed b.* builders already exist. The missing capability is reusable structure combined with collision-proof parameters, generated local-binding handles, explicit references to source symbols, and provenance-preserving instantiation. Consumers currently have to implement these contracts using their own replacement walks, string sentinels, and WeakMaps. New constructed nodes intentionally have no semantic identity until reanalysis. Existing builders, semantic snapshot contract

Possible implementation: an optional template entry point with typed holes and explicit binding ownership; independently mutable instances; predictable comments/spans; and mappings or metadata that let consumers relate generated references to source bindings. It should not silently pretend to recompute whole-module semantics. Keep it optional so ordinary lightweight AST builders do not acquire an eagerly loaded native analyzer dependency.

Evidence: expanded provider builders alone reduced Start compilation workloads 22–23%. Fused cloning plus those builders, measured together, improved them 36–39%. Parameterized HMR templates reduced warm snippet construction 8.507 → 4.163 ms (−51.1%) for 50 routes. The latter excludes printing and application HMR latency.

Contract and scope: useful to RPC generators, HMR transforms, instrumentation, and framework wrappers generally. The prototype’s literal sentinel is not a production API design; collision-proof holes and explicit reference ownership are the upstream value beyond existing builders.

3. Prepare a source once and emit independent variants from sparse edits

Request: support an immutable prepared module that can emit multiple outputs from source-node references and edit plans, avoiding a full JavaScript AST round trip for every output.

Illustrative shape only:

const source = prepare(code)
const result = source.emit({
  remove: [sourceNodeId],
  replace: [{ node: otherNodeId, with: generatedFragment }],
  prepend: [generatedImport],
})

Current gap: native analysis serializes its result and destroys its native tree. Each codegen call subsequently encodes the JavaScript AST and reconstructs a fresh native tree. Reusable JavaScript Module objects and lazy decoding already exist, but they do not remove this per-output boundary work. Analysis lifetime, JS codegen encoding, native reconstruction

Possible implementation: retained native source trees or buffer-backed output views, with stable source node/symbol handles and sparse removals/replacements/insertions. Encode inserted fragments once where possible; let Yuku own efficient reconstruction, printing, comments, and maps. Start with serial independent variants. Native parallel emission could follow, but an initial API need not serialize arbitrary JavaScript visitor callbacks.

Potential benefit: eliminate repeated full-tree decoding, copying, encoding, and native reconstruction. A convenience generateMany(programs) loop alone would not remove those costs. This has the largest potential additional upside, but there is no measured upstream percentage yet. Our batching/worker wins must not be attributed to this unimplemented API.

Contract and scope: this is a larger architectural project requiring explicit disposal/lifetime, memory accounting, output isolation, stale-generation checks, and worker ownership. Prefer opaque/versioned handles over exposing the current internal transfer format. TanStack should retain routing policy, chunk ownership, invalidation, and bundler scheduling; Yuku should own generic edit-and-emit mechanics. A joint profile/prototype is the appropriate next step.

4. Indexed subtree-reference queries and declaration ownership

Request: expose efficient queries such as referencesWithin(sourceNode, { runtimeOnly: true }), together with immutable all-scope declaration-owner groups keyed by source identities, without requiring consumers to walk AST subtrees or repeatedly reconstruct ownership indexes.

Current gap: basic scopes, symbols, Module.references, Symbol.references, parent queries, and lazy indexes already exist. This request is for higher-level indexed queries. For example, capturesOf currently scans the reference table per function; it is not a general indexed subtree-reference query. Existing indexes and capture implementation

Possible implementation: lazily cached source-level reference/ownership indexes with compact stable IDs, scoped to the immutable analysis snapshot. Consumers should still compute output-specific liveness and apply their own side-effect-sensitive extraction policies. Existing root-only route dependency data cannot substitute for all-scope ownership.

Evidence: carrying immutable cleanup metadata in our prototype reduced index reconstruction 1,250 → 250, but improved actual Vite builds only 0.8–3%. Further subtree-query savings have not been measured.

Contract and scope: useful analyzer infrastructure and a complement to the first and third requests, but lower priority than their demonstrated or potential impact. Route-specific chunking and dead-code-removal policy should remain in TanStack.

Suggested discussion order

Start with output-tree provenance as a practical collaboration, followed by analyzed templates. Discuss source-backed edit-and-emit as the larger profiling/prototype opportunity. Keep indexed semantic queries as a supporting request.

Do not request capabilities that already exist: typed builders, reusable analysis objects, lazy AST access, optional source maps, and parse({ semanticErrors: true }). The latter still performs native semantic analysis for diagnostics; its cheaper result transfer is not equivalent to skipping all binding.

For currency: the JSX-reference issue was fixed upstream in #208. At the time of this API review, published 0.11.0—the version used for our experiments—did not include that fix. These requests have only been recorded here; no upstream feature issues have been opened.


Update (2026-10-11, Yuku 0.19.0)

Still recorded only; no upstream feature issue opened yet.

1. Independent output trees with provenance: still missing in 0.19.0, and still our biggest cost. In the current route-splitter benchmark, copying (structuredClone(module.ast) plus the two walks that pair copied and original nodes) is ~38% of the time, ~26% for structuredClone alone. A smaller first version of the ask:

const { program, originalOf } = module.copyAst()
// program: a fresh, mutable tree, deep-equal to module.ast
// originalOf(node): the module.ast node a copied node came from, or null for nodes we created

Copies must be independent of each other. Shared references inside the tree (e.g. ExportSpecifier.local === exported) and RegExp/BigInt literal values must behave as in module.ast.

5. Builders for node kinds that share a type (new): with yuku-ast and @yuku-toolchain/types 0.19.0, a builder returns NodeOfType<K>, which is a union whenever several interfaces share a type:

// NodeOfType<'Property'> = ObjectProperty | BindingProperty
object.properties.push(b.Property({ kind: 'init', key, value, method: false, shorthand: false, computed: false }))
// TS2345: Type 'BindingProperty' is not assignable to type 'ObjectPropertyKind'

// NodeOfType<'ExpressionStatement'> = ExpressionStatement | Directive
const directive: Directive = b.ExpressionStatement({
  expression: b.Literal({ value: 'use server', raw: "'use server'" }),
  directive: 'use server',
})
// TS2322: Type 'ExpressionStatement' is not assignable to type 'Directive'
// TS2345: Type 'BigIntLiteral' is not assignable to type 'StringLiteral' (b.Literal returns the whole Literal union)

#8524 writes ObjectProperty and Directive objects by hand because of this. Ask: builders named after the interfaces (b.ObjectProperty, b.BindingProperty, b.Directive, b.StringLiteral, b.NumericLiteral, b.BooleanLiteral, b.NullLiteral), each returning that exact interface, or b.Property/b.Literal inferring the variant from their input.

Update written with Claude Code (AI-assisted).

@Sheraff
Sheraff added this pull request to stack #8532 September 27, 2026 20:04
Sheraff and others added 3 commits October 14, 2026 17:10
Yuku 0.19.0 is now older than the one-day minimumReleaseAge.

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

Each test passes on main's compiler and fails on this branch until the
behaviour is fixed:
- declarations only an unused local inside them reads are removed with
  their side effects
- a namespace no split option reads is copied into the component chunk
- an unused function rendering the split component keeps it, and its
  imports, in the route module
- a declaration the split component only reads in a type runs twice
- an ambient var merged with an interface is exported from the shared
  module
- a route declared with var in a module-level block is not split
- Yuku bugs (pass once Yuku ships the fixes): yuku-toolchain/yuku#255,
  #256, #257, #258, #259, #260

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

Each test passes on main's compiler and fails on this branch until the
behaviour is fixed:
- declarations only an unused local inside them reads are removed with
  their side effects from the client output
- a middleware chain link wrapped in parentheses or a cast makes the
  client compile throw "Cannot replace a detached output node"
- the source map of a Hydrate chunk names its virtual module id as the
  source
- Yuku bug (passes once Yuku ships the fix): the source map maps a JSX
  attribute's string value to the attribute name, yuku-toolchain/yuku#254

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheraff and others added 4 commits October 15, 2026 09:53
…u migration

Each test fails on main's compiler (57bd1a9) and passes here. Found by
the corpus diff, differential fuzzing, mutation testing, real-app builds
and dev-server runs:
- router-generator records method, async method and accessor option keys
- a declaration only an exported type alias reads stays in the route module
- a component only the split component uses leaves the route module when a
  kept type shares its name
- a declaration a type alias and the split component read runs once
- a dotted namespace keeps its body and the writes to it in the chunk
- route options and route factory calls wrapped in a cast or a non-null
  assertion are split
- a split component reading a route exported through `export { Route }`
  imports it; one also read by an unsplit option stays declared
- component options written as a call with JSX inside (memo(),
  defineComponent()) compile into their chunk
- a split component imports the classic JSX factory the route module exports
- a top-level call reading the split component keeps it in the route module
- split chunks do not import what only the types of route module code read
- split modules import only the classic JSX factories their JSX compiles to

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each test fails on main's compiler (57bd1a9) and passes here. Found by
the corpus diff, differential fuzzing and mutation testing:
- a createIsomorphicFn passed to .handler() produces RPC callers
- import protection finds JSX usages and skips type positions and member
  properties
- an isomorphic fn whose chain link is wrapped in a cast keeps each
  output's implementation
- the client drops a validator whose chain link is wrapped in a cast
- Hydrate split={false} wrapped in a cast keeps function children inline
- the import-protection mock does not reuse a name the module declares

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, other props stay

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

Each test fails on main's compiler and passes here. Found by mutation
testing:
- an enum or a namespace that the loader and a split component read
  directly is initialized once
- Hydrate children reading a Route exported under a string name import it
  (the chunk quotes the export name)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheraff added a commit that referenced this pull request Oct 15, 2026
…ycle and exported-binding bugs

Found by mutation testing #8504 and by checking exported bindings used by
split options. Each pin fails on main and on #8504's head:
- a classic JSX factory the route module creates and the loader reads is
  dropped from the split chunk (created again in the chunk on #8504)
- an unused reference cycle is copied into every split chunk (a row of the
  unreferenced side effects table)
- the "will not be code-split" warning is missing when an exported
  component is the route's only split candidate
- an exported const only a split component renders stays in the entry
  chunk (with a control for the build placement harness)

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

Each test passes on main's compiler and fails on this branch until the
behaviour is fixed:
- the deletion of declarations only an unused local inside them reads
  (same cause as the existing failing tests) also deletes a ReferenceError
  their initializer threw, a declaration only an uncalled self-reading
  function reads, a destructuring that runs a getter, and a statement in a
  function body that only an uncalled nested function reads
- the import-protection rewrite drops a JSX pragma that follows a directive
  when the next statement is a denied import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheraff added a commit that referenced this pull request Oct 15, 2026
…tection rewrite

Each pin fails on main and on #8504's head for the stated reason:
- a type-only import re-exported without `type` (`import type { Settings }`
  or `import { type Settings }`, then `export { Settings }`) loses its import
  but keeps the export, so the module fails to load with "Export 'Settings'
  is not defined": route module and split chunks (code splitter), client and
  SSR outputs (Start compiler)
- rewriting a denied import to the import-protection mock drops the comments
  that lead it (a JSX pragma, a legal comment)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sheraff and others added 19 commits October 15, 2026 15:43
… read into the shared module

An exported function or class that a split route option reads (an exported
page component the split component renders, a store class, a helper) now
moves into the route's shared module with its private dependencies, and the
route module re-exports it from there. Every importer keeps one instance, so
instanceof checks and module state agree, and when the shared module has no
top-level effects it is marked side-effect free (Vite/rolldown
moduleSideEffects, webpack/Rspack factoryMeta), so the bundler can leave it
and the imports only it uses out of the route module's chunk.

Exports the shared module cannot take stay in the route module: exported
split targets, bindings that depend on Route, bindings code staying behind
reassigns, and initializers with effects that would overtake effects of
statements staying behind. Of those, a stateless exported function (one that
reads Route, for example) is declared in the split chunk as on main.

Replaces the test pinning the import-from-route-module rule with the shared
module behaviour, and adds build-level tests for startup placement and one
shared instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…list the Yuku migration fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ompiled before their route module

In dev, an edit invalidates the route module, its split chunks and its
shared module together, and the browser requests them again in no fixed
order. The shared and split transforms read the shared-binding set the
route module's transform stored for the file, so requested first they
compile the edited source with the previous set: a removed binding is
still exported ("Export 'suffix' is not defined", cached until the next
edit) and an added one is copied into each chunk instead of shared.

Already on main for a binding two split options share; the exported
component's private dependencies now in the shared module make it reachable
by removing a helper of an exported component a split component renders.
The tests request the modules out of order through a Vite dev server and
compare them with a server started after the edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Replace the two-row failing middleware test with a matrix: 8 wrappers
  (parentheses, double parentheses, `as any`, `as unknown as T`,
  `satisfies`, `!`, `<any>x`, a nested combination) x 3 positions (the
  wrapped part ends at `.server()` and `.client()` follows; the same inside
  a wrapped chain; after `.middleware().validator().server()`). Each row
  checks that the client output has no server-only import, server body or
  validator body, that the evaluated client chain only runs `.client()`,
  and that SSR keeps and runs every link. These pass on main and fail here
  ("Cannot replace a detached output node").
- A wrapped middleware `.validator()` link is stripped on the client.
- An isomorphic fn with a wrapped client-first link keeps both
  implementations.
- A wrapped callee of createServerOnlyFn, createIsomorphicFn (both orders)
  and createClientOnlyFn is compiled: main left these calls untransformed,
  shipping the server implementation and its imports to the client and
  running a client-only fn during SSR.

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

The dead-code pass keeps declarations that were already unused before the
transform ("initially unused") wherever their enclosing code survives. It
decided what was unused from reference cycles alone, so a read by an unused
local nested inside the declaration itself counted as a use: in
`const plugin = { id: register(), m() { const self = plugin } }` the nested
`self` made `plugin` "used", `self` only survives with `plugin`, and nothing
else kept `plugin`, which was deleted with its `register()` call. The same
happened for a class whose method reads it, a TDZ read, a getter run by a
destructuring, and an uncalled (nested) function reading itself through a
local, together with the declarations only that function reads.

This reached the code splitter's route module, the route HMR compile
without code splitting, the Start compiler outputs and Hydrate chunks;
main kept all of them. Only reads by code that survives now count: any
declaration the liveness pass would drop from the unchanged source is
initially unused too. The tests for the route HMR compile without code
splitting are added to the existing table.

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

An unused function that renders the split component, directly, through a
helper or from an inline option (`function Unused() { return <Page /> }`),
kept the component and its imports in the route module, so they loaded
with the route and the split was defeated; the chunk declared a second
copy. The function was initially unused, survived as written, and kept
everything it reads alive.

As decided for this migration, it stays as written but no longer keeps
what moves into split chunks in the route module: its reference dangles,
and nothing can call it. Code that can run is not affected: an exported
function, a function staying code reads (a route option, a module-level
call, a callback an unused subscription receives) keeps its dependencies,
which are shared as before. The removal pass now takes the bindings the
split values read and the declarations they depend on (`moved`), and
ignores uses of them by code that cannot run: unused functions and what
they declare. Imports are not moved: the unused function keeps the imports
it reads, as on main.

The table row for unused functions calling a helper of the split component
is updated to the new behaviour, and the unused-function test covers the
three shapes; new tests check that functions code can still call keep
the component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ads in a type

TypeScript erases type positions, so the migration no longer counts them as
reads: `const value = init(1)` that the split component only names in a type
(`{} as typeof value`, `z.infer<typeof schema>`) is initially unused. The
dead-code pass keeps initially unused declarations wherever their enclosing
code survives, in every emitted module, so the route module kept it and the
chunk declared a second copy: `init(1)` ran twice. main moved it into the
chunk, so it ran once.

A split chunk no longer keeps an initially unused declaration it only reads
in types: it is declared once, in the route module, where it runs as
before. Declarations a chunk reads at runtime are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An instantiated namespace nothing reads, such as
`namespace Unused { export const value = track() }`, is initially unused, so
the dead-code pass kept it with its body in every emitted module, and its
initializer ran again whenever a split chunk loaded. main left at most an
empty namespace in the chunk.

A split chunk no longer keeps an initially unused namespace that nothing in
it reads; the route module keeps it and runs its body once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`declare var readVersion: ReadVersion` merged with `interface readVersion`
has no runtime declaration: the environment provides the value. The shared
module skipped ambient bindings by Yuku's `Ambient` flag, which is only set
when every declaration is `declare`d, and an interface is not. So a shared
declaration reading it (`const version = readVersion()`) put it in the
shared module's exports, which then failed to load with "Export
'readVersion' is not defined".

A binding whose declarations are all `declare`d or types is now never
shared or exported, and chunks read the global.

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

`if (x) { var Route = createFileRoute('/')({ ... }) }` with
`export { Route }` declares a module binding, and main split it. Route
detection treated every factory call outside the module scope, a block
included, as created inside a function: the route was left unsplit, without
route HMR, and the warning said it was created inside a function.

A factory call in a module-level block (`if`, `try`, a bare block, a loop,
a `switch`) now counts as module code; only one inside a function, a
class body or a namespace is a route factory.

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

The candidate walk skips the inner links of a chain (`a.b().c()`: only the
outermost call is compiled), but only when the link's parent is the member
expression. In `(createMiddleware().server(s) as any).client(c)` a wrapper
sits in between, so the inner `createMiddleware().server(s)` became a second
candidate. The outer candidate's chain walk already looks through wrappers
and stripped `.server()` on the client; stripping it again from the second
candidate threw "Cannot replace a detached output node". main built these.

The skip check now looks through parentheses and TypeScript assertions
(`as`, `satisfies`, `!`, `<T>x`) between a link, its member and the call.

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

A Hydrate chunk was generated with its virtual module id as the filename,
so its source map named `file.tsx?tss-hydrate=…` as the source: each chunk
showed up as a separate fake file in devtools and error trackers. main's
maps named the file itself. The chunk is now generated with the file's id.

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

The route module's transform computed the bindings a route's split modules
share and stored them by file; the `?tsr-shared=1` and `?tsr-split=`
transforms read that stored set. In dev an edit invalidates all three
modules, which are requested again in no fixed order: requested before the
route module, the shared module and chunks compiled the edited source with
the previous set. A removed binding was still exported ("Export 'suffix' is
not defined", cached until the next edit) and an added one was copied into
each chunk instead of shared. main had this for a binding two split options
share; the migration's move of exported components' private dependencies to
the shared module made it easy to hit.

The stored map is removed. Each of the three transforms resolves the
route's groupings (from the code, `splitBehavior` or the default) and its
shared bindings from the code it receives, with one function, reusing the
analysis it compiles with.

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

The shared module's transform returned `moduleSideEffects: false` when the
module is pure and left the key out otherwise. In `vite build --watch`,
rolldown then kept the flag of an earlier build
(rolldown/rolldown#11223): after an edit gave the
shared module a top-level effect, the effect was left out of the startup
chunk. An impure shared module now returns `moduleSideEffects: true`, the
default analysis. Webpack and Rspack replace a module's factory metadata
on every compilation, so their path needs no change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vite marks a module self-accepting only when its code contains a literal
`import.meta.hot.accept(`. Solid route modules with automatic code
splitting and all Vue route modules rely on the route HMR code for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
React Refresh hot-updates a module only when all its exports are
components. A shared module that holds an exported component and the
private values it reads reloads the page in Vite bundled dev, and needs an
extra invalidation round trip in unbundled dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt.meta.hot.accept

Vite marks a module self-accepting only when its code contains a literal
`import.meta.hot.accept(`. The route HMR code called `accept` on a local
alias, so a route module was an HMR boundary only when a framework refresh
plugin made it one: every edit of a Solid route file with automatic code
splitting, of a Solid route file whose components moved to its shared
module, and of any Vue route file reloaded the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… from its shared values

React Refresh hot-updates a module only when all its exports are
components. Since exported functions that a split option reads moved to the
shared module with their private dependencies, an exported component and
the private values it reads ended up in one module: in Vite bundled dev
every edit of it reloaded the page and lost state, and in unbundled dev it
needed an invalidation round trip ("Could not Fast Refresh").

For React, the shared bindings that look like components, that no other
shared binding reads and that assign no other shared binding, now go to `?tsr-shared=components`, which imports
the other shared bindings from `?tsr-shared=1`; the values module never
imports it. Each transform computes the split from the code it compiles,
and the components module is side-effect free only when the values module
it imports is too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d and Vue route HMR fix

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

Development

Successfully merging this pull request may close these issues.

1 participant