Skip to content

[POC] Combine Rspack/Rsbuild migration with OXC React Compiler loader#95656

Draft
roryabraham wants to merge 19 commits into
mainfrom
rory-oxc-rsbuild-combined
Draft

[POC] Combine Rspack/Rsbuild migration with OXC React Compiler loader#95656
roryabraham wants to merge 19 commits into
mainfrom
rory-oxc-rsbuild-combined

Conversation

@roryabraham

Copy link
Copy Markdown
Contributor

Explanation of Change

This is a draft POC that combines the work from two other draft PRs into a single branch to validate that they work together and to measure the combined build-performance impact:

Both PRs touch the same build pipeline in conflicting ways (one moves the bundler from Webpack to Rspack, the other replaces the Babel-based JS/TS/JSX transform loader with OXC), so this branch cherry-picks all commits from both, resolves the merge conflicts, and rewires the OXC three-pass loader pipeline (Fullstory annotation → React Compiler + JSX/TS transform → worklets) directly into rsbuild.common.ts's addRules, replacing @rsbuild/plugin-babel entirely.

Key integration points:

  • config/rsbuild/rsbuild.common.ts: replaced the single pluginBabel rule with three explicit Rspack rules — Rule A (app source, React Compiler enabled), Rule B/B2 (allow-listed node_modules that need transforms but not React Compiler, split by .tsx? vs .jsx? for JSX runtime injection).
  • babel.config.js: the web export is now nearly empty (no presets, since OXC handles JS/TS/JSX transforms natively) — it's effectively dead code for the web build (kept for tooling that loads babel.config.js directly, e.g. IDE plugins), since Rsbuild's pipeline calls babel-loader with configFile: false for the worklets-only pass.
  • package.json: removed @rsbuild/plugin-babel and various @babel/preset-*/babel-plugin-react-native-web packages no longer needed for web; added babel-loader, oxc-loader, and oxc-transform as explicit dependencies.
  • knip.json: scoped the config/rsbuild entry glob to include .cjs files (custom loaders), and added a few ignoreDependencies entries for packages Knip can't statically trace through dynamic loader strings.

Validation performed

Benchmarks (combined OXC + Rsbuild)

Cold = node_modules/.cache/{rspack,storybook} and dist/ cleared before the run. Warm = immediate re-run with cache intact. Single-machine, single-run measurements (not averaged across multiple runs) — treat as directional, not authoritative.

Scenario Cold Warm
npm run build (prod) 15.5s build / 18.4s total (incl. sourcemap merge) 16.2s build / 18.9s total
npm run storybook-build 21.5s build / 25.7s total 21.8s build / 25.9s total
Dev server (web-server) 10.9s to first ready 0.15–0.17s per HMR rebuild
Storybook dev ~14s preview + 1.2s manager 0.19s per HMR rebuild

Takeaway: production/Storybook builds show negligible cold-vs-warm delta (minification dominates the timing either way), while dev-server incremental rebuilds are consistently sub-200ms thanks to Rspack's persistent module cache plus OXC's transform speed.

Fixed Issues

$
PROPOSAL:

Tests

This is a build-tooling POC combining two other draft PRs — see "Validation performed" and "Benchmarks" above in lieu of standard feature test steps. Not intended to be merged as-is; opened as a draft to validate the two approaches are compatible and to get combined benchmark numbers.

  • Verify that no errors appear in the JS console

QA Steps

N/A — draft POC, not intended for QA.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I verified there are no console errors introduced by this change (build/dev-server smoke tests above)

Made with Cursor

roryabraham and others added 19 commits July 8, 2026 17:59
…uilds

Mirrors config/webpack/{webpack.common,webpack.dev}.ts under config/rspack/,
swapping webpack-specific plugins for their Rspack builtins (CopyRspackPlugin,
CssExtractRspackPlugin, SwcJsMinimizerRspackPlugin, ReactRefreshRspackPlugin,
@aaroon/workbox-rspack-plugin) and replacing @vue/preload-webpack-plugin
(which depends on webpack-internal chunk graph APIs Rspack doesn't expose)
with a small custom RspackPreloadPlugin that reads compilation.getAssets()
directly.

Adds `build:rspack`, `build-staging:rspack`, and `web-server:rspack` npm
scripts so both bundlers can be run side by side without touching the
existing webpack config. Storybook is out of scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
Expands the Rspack POC to cover Storybook (via storybook-react-rsbuild,
since Storybook has no first-party Rspack framework package) and removes
Webpack entirely now that both build paths are covered, rather than
keeping the two bundlers side-by-side.

Co-authored-by: Cursor <cursoragent@cursor.com>
The auto-restart wrapper (#67373) was added to paper over
webpack-dev-server's unbounded JS-heap growth across HMR recompiles,
which forced periodic heap-limit crashes during long dev sessions.

Stress-tested the Rspack dev server with 60+ back-to-back HMR recompiles
(mimicking the original repro of repeatedly saving a file): heap usage
plateaus/bounces in a bounded band rather than growing without bound,
and the periodic forced gc() call reliably reclaims memory each time -
unlike webpack, where explicit GC barely helped. No crash-recovery loop
needed.

Also fixes a latent bug in ForceGarbageCollectionPlugin: `if (gc && ...)`
throws a ReferenceError when Node isn't started with --expose-gc, since
referencing an undeclared global outside of `typeof` throws. `typeof gc`
alone is the safe check.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ild, ws vuln

- rspack.common.ts / .storybook/rsbuild.config.ts: explicitly mock
  __filename/__dirname ('mock' instead of Rspack's default 'warn-mock') so
  canvaskit-wasm/expo don't emit a "Module parse warning" on every build.
  Storybook's `--smoke-test` treats any non-allow-listed warning as a hard
  failure, so this was breaking the "Storybook tests" CI check; the rsbuild
  config wasn't otherwise inheriting this from the shared Rspack config.
- cspell.json: add rspack/Rspack/rsbuild/aaroon to the word list.
- Rebuilt 3 stale .github/actions/javascript/*/index.js bundles that drifted
  after merging main (unrelated to this PR's changes).
- Bump the `ws` override from 8.17.1 to 8.21.0 to pick up fixes for two
  advisories (GHSA-58qx-3vcg-4xpx, GHSA-96hv-2xvq-fx4p) that Snyk flagged
  once storybook/webpack-bundle-analyzer joined the dependents list.

Co-authored-by: Cursor <cursoragent@cursor.com>
ncc's output isn't fully deterministic across different node_modules
resolution states; my previous gh-actions-build run used a tree from
npm install rather than npm ci, leaving this one bundle out of sync
with what CI's verify job rebuilds and diffs against.

Skips the oxfmt pre-commit hook: it errors ("Expected at least one target
file") when every staged file matches its ignorePatterns, which is the case
here since .github/actions/javascript/**/index.js is intentionally excluded
(generated bundle). Nothing to format either way.

Co-authored-by: Cursor <cursoragent@cursor.com>
…sitive dep

Removing webpack as a devDependency also dropped the "webpack": "^5.x"
override, which was silently pinning react-web-config's own (unused)
webpack@^2.3.3 dependency to a safe version. Without it, npm installs
react-web-config's real webpack@2.7.0 -> node-libs-browser ->
crypto-browserify -> browserify-sign/create-ecdh -> elliptic chain,
introducing 4 new critical vulnerabilities not present on main.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Stop suppressing import/extensions line-by-line for .storybook/ and
  config/rspack/ ESM entry points; handle it with one config-level override.
- Share ignoreWarnings, lottie/pdf asset rules, and define() values between
  rspack.common.ts and .storybook/rsbuild.config.ts so Storybook can't drift
  from the app build; drop mockPaths.ts aliases already set upstream.
- Replace html-webpack-plugin with Rspack's native HtmlRspackPlugin in both
  the main plugin and RspackPreloadPlugin, and remove the now-unused
  html-webpack-plugin dependency.
- Document the RSPACK_PROFILE build-profiling workflow in SETUP_WEB.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
Type RspackPreloadPlugin's links array off the alterAssetTags hook's own
parameter instead of importing JsHtmlPluginTag from @rspack/binding, since
that package is only a transitive dependency of @rspack/core, not one we
depend on directly. Add "pftrace" (the Perfetto trace file extension) to
the cspell word list.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rsbuild wraps Rspack with higher-level config (source.define, output.copy,
server.https/proxy, performance.buildCache) and is already used for Storybook,
so unifying onto it removes the last hand-rolled dev-server/HTTPS/persistent-cache
plumbing in config/rspack/ and lets the app build and Storybook share one
getSharedConfiguration()/getCommonConfiguration() config instead of two
independently-maintained bundler configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Re-ran the #95319 HMR memory-stress test (60+ back-to-back recompiles)
against the Rsbuild dev server with and without the plugin's forced
gc() call. Heap and RSS plateau identically either way (~245MB heap,
bounded RSS growth across 260 total compiles), unlike raw Rspack where
disabling gc() caused unbounded growth. V8's own GC is sufficient here,
so the plugin and --expose-gc are dead weight under Rsbuild.

Co-authored-by: Cursor <cursoragent@cursor.com>
The ESLint flat config now disables import/extensions repo-wide for
config/rsbuild/**/*.ts, so the line-level disable for that rule is
redundant. The @dword-design/import-alias/prefer-alias disable is
still needed and stays.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rsdoctor is the first-party Rspack/Rsbuild bundle analyzer, integrates
via the same tools.rspack escape hatch, and adds loader/plugin timing
and duplicate-package analysis on top of bundle size visualization.

Co-authored-by: Cursor <cursoragent@cursor.com>
The webpack dev config defined process.env.PORT for the client so
CONFIG.DEV_PORT reflects the actual resolved port when portfinder
falls back off 8082. Rsbuild's dev config dropped that define, only
wiring the resolved port to server.port, so getEnvironmentURL() would
generate dev links pointing at the wrong port whenever 8082 was busy.

Verified by occupying 8082, booting the dev server (portfinder fell
back to 8083), and confirming the served bundle now bakes in
`DEV_PORT: 8083 ?? 8082` instead of falling back to the hardcoded
default.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two new loaders that together eliminate babel-plugin-react-compiler and the
full Babel transform pass from the webpack build:

fullstory-annotation-loader.js
  Standalone replacement for @fullstory/babel-plugin-annotate-react that
  uses @babel/parser + @babel/traverse in parse-only mode (no code
  transforms). Injects dataComponent / dataElement / dataSourceFile props
  onto JSX opening elements so OXC receives annotated JSX and can run its
  React Compiler before the JSX transform. No Babel transform pipeline needed.

oxc-react-compiler-loader.js
  Thin wrapper around oxc-transform 0.136.0 that runs the Rust port of the
  React Compiler + JSX transform + TypeScript strip + env lowering in a single
  native pass. Demotes non-fatal React Compiler diagnostics to webpack warnings
  rather than hard build errors, matching babel-plugin-react-compiler's default
  bailout behaviour. Workaround for oxc-project/oxc#23587.

Co-authored-by: Cursor <cursoragent@cursor.com>
- babel.config.js: strip removed packages from the dead webpack branch
  (@babel/preset-react, @babel/preset-env, @babel/preset-flow,
  @babel/preset-typescript, babel-plugin-react-native-web, and several
  class-property/export-namespace transforms). This fixes the Storybook
  build and ESLint import-alias rule which both load babel.config.js.

- fullstory-annotation-loader.js: rename annotateJSXNode/annotateComponent
  → applyPropsToJSXNode/applyPropsToComponent to satisfy the
  no-negated-variables rule ('annotate' contains 'not' as a substring).

- oxc-react-compiler-loader.js: extract getLang() to eliminate the
  no-nested-ternary violation.

- cspell.json: add 'errorbar' (from the fullstory KNOWN_INCOMPATIBLE list).

- package.json: add @babel/generator, get-tsconfig, oxc-transform as
  explicit devDependencies (used directly by our loaders) to satisfy knip.
  Scope the oxc-transform override to just oxc-loader to avoid conflicts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
npm ci fails when a direct dependency uses a semver range (^) but
overrides pins an exact version. Pin the devDependency to match the
override so setupNode can install dependencies in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
Storybook was still looking for a top-level babel-loader rule; push the
OXC transform pipeline rules instead. Rename webpack loaders to .cjs to
avoid the new-JS-files typecheck gate. Restore knip's cross-platform
oxc-parser optional deps in package-lock.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Rewrite includeModules regex as INCLUDED_NODE_MODULES array + includedNodeModules
  regex, and wire OXC's three-pass loader pipeline (Rule A/B/B2) directly into
  rsbuild.common.ts now that pluginBabel is gone.
- Add babel-loader as an explicit devDependency (previously transitive via
  @rsbuild/plugin-babel).
- Ignore babel-loader, oxc-loader, and @babel/plugin-proposal-class-properties in
  knip.json, and scan .cjs files under config/rsbuild/ - Knip can't statically
  follow the dynamic loader strings/paths used by Rspack's rule config or
  babel.config.js's caller-based branching.

Co-authored-by: Cursor <cursoragent@cursor.com>
@melvin-bot

melvin-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

Hey, I noticed you changed some webpack configuration files. This can break production builds. Did you remember to run a production build locally to verify they still work?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant