Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a2886f6
perf(webpack): add Fullstory + OXC React Compiler webpack loaders
roryabraham Jun 18, 2026
6fe8a45
perf(webpack): swap babel-loader + babel-plugin-react-compiler for OX…
roryabraham Jun 18, 2026
f669023
fix(ci): fix ESLint, Prettier, spellcheck, knip, and Storybook failures
roryabraham Jun 18, 2026
976c860
Merge remote-tracking branch 'origin/main' into rory-oxc-loader-v2
roryabraham Jul 5, 2026
57db725
chore: bump babel-plugin-react-compiler to 20260507 experimental
roryabraham Jul 5, 2026
4aa1fee
fix(ci): pin babel-plugin-react-compiler to fix npm ci override conflict
roryabraham Jul 5, 2026
79cc577
Fix CI: Storybook webpack, knip lockfile, spellcheck
roryabraham Jul 5, 2026
2b5f010
Merge remote-tracking branch 'origin/main' into rory-oxc-loader-v2
roryabraham Jul 11, 2026
dc55007
fix: exclude OXC-handled files from Rsbuild's default SWC loader; fal…
roryabraham Jul 11, 2026
14880be
fix: use official oxc-webpack-loader instead of community oxc-loader
roryabraham Jul 11, 2026
a1a0f47
fix: allowlist oxc-react-compiler-loader warnings in Storybook smoke …
roryabraham Jul 11, 2026
3d8c8f3
refactor: convert custom Rspack loaders from CJS to ESM
roryabraham Jul 11, 2026
7fd9ae5
docs: clarify why babel.config.js's web config is still needed
roryabraham Jul 11, 2026
b8638db
docs: remove stale reference to removed babel presets
roryabraham Jul 11, 2026
6d1c11b
fix: recognise -> recognize (British spelling flagged by spellcheck)
roryabraham Jul 11, 2026
5c82c7c
perf: skip Babel worklets pass for files that cannot contain a worklet
roryabraham Jul 11, 2026
7c48b39
perf: use case-sensitive includes() for worklets-loader's pre-check
roryabraham Jul 11, 2026
ca0cf37
Drop hand-rolled Fullstory annotation logic for upstream plugin
roryabraham Jul 11, 2026
bc9b5e3
perf: skip Fullstory Babel pass entirely for .ts files
roryabraham Jul 11, 2026
0bd3ded
fix(rsbuild): enable persistent rspack cache for build, not just dev
roryabraham Jul 11, 2026
1a0bd33
Upgrade @fullstory/babel-plugin-annotate-react to 2.4.0
roryabraham Jul 11, 2026
b651008
Simplify loader ESLint override comment, seatbelt the negated-variabl…
roryabraham Jul 11, 2026
daa2db9
Group custom loaders under config/rsbuild/loaders/
roryabraham Jul 11, 2026
5c3f57f
Simplify comment in fullstory-annotation-loader
roryabraham Jul 11, 2026
1ef1f7f
Simplify comments in oxc-react-compiler-loader
roryabraham Jul 11, 2026
2e0779e
Simplify comments in worklets-loader
roryabraham Jul 11, 2026
19b6f3b
Merge included-node_modules JS/TS rules, DRY loader config, drop Rule…
roryabraham Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 23 additions & 39 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,44 +27,28 @@ function traceTransformer() {
};
}

/**
* Setting targets to node 20 to reduce JS bundle size
* It is also recommended by babel:
* https://babeljs.io/docs/options#no-targets
*/
const defaultPresetsForWeb = ['@babel/preset-react', ['@babel/preset-env', {targets: {node: 20}}], '@babel/preset-flow', '@babel/preset-typescript'];
const defaultPluginsForWeb = [
['babel-plugin-react-compiler', ReactCompilerConfig], // must run first!
// Adding the commonjs: true option to react-native-web plugin can cause styling conflicts
['react-native-web'],

'@babel/transform-runtime',
'@babel/plugin-proposal-class-properties',
['@babel/plugin-transform-object-rest-spread', {useBuiltIns: true, loose: true}],

// We use `@babel/plugin-transform-class-properties` for transforming ReactNative libraries and do not use it for our own
// source code transformation as we do not use class property assignment.
'@babel/plugin-transform-class-properties',
'@babel/plugin-proposal-export-namespace-from',
// Keep it last
'react-native-worklets/plugin',
'@babel/plugin-transform-export-namespace-from',
];

defaultPluginsForWeb.push([
'@fullstory/babel-plugin-annotate-react',
{
native: true,
},
]);

if (process.env.DEBUG_BABEL_TRACE) {
defaultPluginsForWeb.push(traceTransformer);
}

// Rsbuild's web build no longer reads this: its JS/TS/JSX pipeline (see
// config/rsbuild/rsbuild.common.ts) calls OXC directly with configFile:false, bypassing
// this file entirely. This `web` config object is still read, though — ESLint's
// @babel/eslint-parser loads babel.config.js for every file it lints (caller name is
// always undefined or '@babel/eslint-parser', never 'metro'/'babel-jest', so it always
// falls into this branch below) to resolve syntax plugins like `exportNamespaceFrom`
// needed to parse the same syntax the web bundle uses. OXC handles JSX/TypeScript/env-target
// transforms natively for the web build, so this branch only lists the plugins ESLint's
// parser needs for syntax it wouldn't otherwise recognize.
const web = {
presets: defaultPresetsForWeb,
plugins: defaultPluginsForWeb,
presets: [],
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
'react-native-worklets/plugin',
'@babel/plugin-transform-export-namespace-from',
[
'@fullstory/babel-plugin-annotate-react',
{
native: true,
},
],
],
};

const metro = {
Expand Down Expand Up @@ -181,9 +165,9 @@ module.exports = (api) => {
}

// For `react-native` (iOS/Android) caller will be "metro"
// For the web build (Rspack) caller will be "babel-loader"
// For jest, it will be babel-jest
// For `storybook` there won't be any config at all so we must give default argument of an empty object
// The web build and Storybook (Rsbuild) don't call into this file at all — their JS/TS/JSX
// pipeline goes through OXC directly (see config/rsbuild/rsbuild.common.ts), bypassing Babel.
const runningIn = api.caller((args = {}) => args.name);
if (!process.env.KNIP) {
console.debug(' - running in: ', runningIn);
Expand Down
8 changes: 8 additions & 0 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,14 @@ const config = defineConfig([
},
},

// Rspack loaders receive their `this` from the bundler, and it's standard practice to use it
{
files: ['config/rsbuild/loaders/*-loader.mjs'],
rules: {
'no-invalid-this': 'off',
},
},

{
files: ['**/en.ts', '**/es.ts'],
rules: {
Expand Down
3 changes: 2 additions & 1 deletion config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2323,5 +2323,6 @@
"../../tests/utils/getIsUsingFakeTimers.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../rsbuild/CustomVersionFilePlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../rsbuild/ModuleInitTimingPlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../rspack/RspackPreloadPlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../rsbuild/loaders/fullstory-annotation-loader.mjs" "rulesdir/no-negated-variables" 1
"../rsbuild/rsbuild.common.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../rspack/RspackPreloadPlugin.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
42 changes: 42 additions & 0 deletions config/rsbuild/loaders/fullstory-annotation-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Thin wrapper around the upstream @fullstory/babel-plugin-annotate-react plugin, invoked directly via @babel/core rather than
* going through babel-loader.
*
* It is an optimization. Two filters skip Babel entirely when a file cannot possibly contain JSX:
* - `.ts` files: TypeScript's grammar disallows JSX syntax outside `.tsx`
* - All other extensions (.tsx, .jsx, .js): fall back to a `source.includes('<')`, a quick check for JSX
*/

import babel from '@babel/core';
import path from 'node:path';

export default function fullstoryAnnotationLoader(source) {
const ext = path.extname(this.resourcePath);
if (ext === '.ts') {
return source;
}
if (!source.includes('<')) {
return source;
}

const callback = this.async();
babel.transform(
source,
{
babelrc: false,
configFile: false,
filename: this.resourcePath,
plugins: [['@fullstory/babel-plugin-annotate-react', {native: true, reactCompiler: true}]],
parserOpts: {plugins: ['jsx', 'typescript']},
sourceMaps: !!this.sourceMap,
},
(error, result) => {
if (error) {
callback(error);
return;
}
callback(null, result.code, result.map ?? undefined);
},
);
return undefined;
}
115 changes: 115 additions & 0 deletions config/rsbuild/loaders/oxc-react-compiler-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Thin wrapper around oxc-transform that adds React Compiler support while
* demoting non-fatal React Compiler diagnostics from hard build errors to
* webpack warnings.
*
* This works around oxc-project/oxc#23587 (fixed in a future
* release), which caused the build to fail instead of bailing out on components
* that violate the Rules of React — the same behaviour as babel-plugin-react-compiler.
* We need the workaround for now until a new oxc release containing the fix adds back `reactCompiler`.
*/

import {getTsconfig} from 'get-tsconfig';
import path from 'node:path';
import {transform} from 'oxc-transform';

function extractTsconfigOptions(rootContext) {
try {
const tsconfig = getTsconfig(rootContext);
if (!tsconfig) {
return {};
}
const {compilerOptions} = tsconfig.config;
if (!compilerOptions) {
return {};
}
const opts = {};
if (compilerOptions.target) {
const map = {
ES2015: 'es2015',
ES2016: 'es2016',
ES2017: 'es2017',
ES2018: 'es2018',
ES2019: 'es2019',
ES2020: 'es2020',
ES2021: 'es2021',
ES2022: 'es2022',
ESNEXT: 'esnext',
};
const t = map[compilerOptions.target.toUpperCase()];
if (t) {
opts.target = t;
}
}
return opts;
} catch {
return {};
}
}

function getLang(ext) {
if (ext === 'tsx') {
return 'tsx';
}
if (ext === 'ts') {
return 'ts';
}
// JSX is legal syntax in plain .js files too (both rules that route here match .js and .jsx
// identically), so .js gets the same JSX-enabled parser as .jsx rather than a stricter one.
return 'jsx';
}

export default async function oxcReactCompilerLoader(source) {
const callback = this.async();
try {
const options = this.getOptions() || {};
const sourceMaps = options.sourcemap !== undefined ? options.sourcemap : !!this.sourceMap;
const tsconfigOptions = extractTsconfigOptions(this.rootContext);

const resourcePath = this.resourcePath;
const ext = path.extname(resourcePath).slice(1);
const lang = getLang(ext);

const transformOptions = {
...tsconfigOptions,
lang,
target: options.target,
jsx: options.jsx,
reactCompiler: options.reactCompiler,
sourcemap: sourceMaps,
cwd: this.rootContext,
};

let result = await transform(resourcePath, source, transformOptions);

// Demote React Compiler diagnostics to webpack warnings instead of
// hard errors (workaround for oxc-project/oxc#23587).
const rcErrors = (result.errors || []).filter((e) => e.message && e.message.includes('[ReactCompiler]'));
const fatalErrors = (result.errors || []).filter((e) => !e.message?.includes('[ReactCompiler]'));

for (const e of rcErrors) {
this.emitWarning(new Error(`oxc-react-compiler-loader: ${e.message}`));
}

if (fatalErrors.length > 0) {
const msg = fatalErrors.map((e) => `${e.message}${e.codeframe ? `\n${e.codeframe}` : ''}`).join('\n\n');
callback(new Error(`Oxc transform errors:\n${msg}`));
return;
}

// A React Compiler error makes oxc-transform bail out
// of the whole transform and return empty code, not just skip the optimization.
// Re-run without the compiler to fall back to plain JSX/TS transform output.
if (!result.code && rcErrors.length > 0) {
result = await transform(resourcePath, source, {...transformOptions, reactCompiler: false});
}

if (sourceMaps && result.map) {
callback(null, result.code, result.map);
} else {
callback(null, result.code);
}
} catch (err) {
callback(err);
}
}
35 changes: 35 additions & 0 deletions config/rsbuild/loaders/worklets-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Thin wrapper around react-native-worklets/plugin that skips babel entirely for the ~97.5% of files that don't contain worklets.
*/

import babel from '@babel/core';

function referencesWorklet(source) {
return source.includes('react-native-reanimated') || source.includes('worklet');
}

export default function workletsLoader(source) {
if (!referencesWorklet(source)) {
return source;
}

const callback = this.async();
babel.transform(
source,
{
babelrc: false,
configFile: false,
filename: this.resourcePath,
plugins: ['react-native-worklets/plugin'],
sourceMaps: !!this.sourceMap,
},
(error, result) => {
if (error) {
callback(error);
return;
}
callback(null, result.code, result.map ?? undefined);
},
);
return undefined;
}
Loading
Loading