Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/babel-loader-flow-enums.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": patch
---

Fix Flow enums disappearing in `babel-loader` and `babel-swc-loader`: `@react-native/babel-preset` strips enum declarations before its enum plugin runs, leaving references such as React Native's `VirtualViewMode.cast()` undefined at runtime. Both loaders now run the preset's `babel-plugin-transform-flow-enums` ahead of the preset, so enums are lowered to `flow-enums-runtime` (resolved from the project or from `react-native`) in every Flow file, with or without an `@flow` pragma. Flow `component` declarations are also lowered for the project's React version instead of always targeting React 19 (`hermesParserOverrides.reactRuntimeTarget` still takes precedence).
5 changes: 5 additions & 0 deletions .changeset/babel-loader-input-source-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": patch
---

Fix `babel-loader` and `babel-swc-loader` failing with `SyntaxError: "[object Object]" is not valid JSON` when a previous loader passes its source map as an object, e.g. `flow-loader` (for lowered Flow syntax or with `pretty: true`) or the stock `babel-loader` used by `getCodegenTransformRules`. Both loaders now accept serialized and object source maps.
5 changes: 5 additions & 0 deletions .changeset/flow-loader-component-syntax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": patch
---

Fix `flow-loader`, and with it `getJsTransformRules`, emitting invalid JavaScript for Flow `component` and `hook` declarations, enums and `match` expressions, which React Native ships since 0.81. Files using them are now compiled with the project's React Native parser (`hermes-parser` or `flow-parser`), `babel-plugin-transform-flow-enums` and `@babel/plugin-transform-flow-strip-types`, while other files still go through `flow-remove-types`. Lowered enums use `flow-enums-runtime`, resolved from the project or from `react-native`. `component` declarations follow the project's React version (`ref` as a prop on React 19, `forwardRef` on React 18). Also fix `flow-loader` crashing under webpack with `Maximum call stack size exceeded`.
4 changes: 4 additions & 0 deletions apps/tester-app/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ describe('bundle command', () => {
bundler: 'rspack',
configFile: './rspack.config.mjs',
},
{
bundler: 'rspack-swc',
configFile: './rspack.swc.config.mjs',
},
])('using $bundler', ({ bundler, configFile }) => {
const bundleCommand = commands.find((command) => command.name === 'bundle');
if (!bundleCommand) throw new Error('bundle command not found');
Expand Down
23 changes: 23 additions & 0 deletions apps/tester-app/__tests__/configs/rspack.swc.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as Repack from '@callstack/repack';
import baseConfig from '../../rspack.config.mjs';

// Same app, but JS goes through the SWC-native rules instead of babel-swc-loader,
// so React Native's own Flow sources are compiled by `getJsTransformRules`
export default (env) => {
const config = baseConfig(env);
const assetRules = config.module.rules.filter(
(rule) => rule.use?.loader !== '@callstack/repack/babel-swc-loader'
);

return {
...config,
module: {
...config.module,
rules: [...Repack.getJsTransformRules(), ...assetRules],
},
output: {
...config.output,
path: process.env.TEST_WEBPACK_OUTPUT_DIR,
},
};
};
145 changes: 136 additions & 9 deletions packages/repack/src/helpers/resolveReactNativeParser.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,147 @@
import { createRequire } from 'node:module';
import type { PluginItem, types } from '@babel/core';

export function resolveReactNativeParser(projectRoot: string) {
function resolvePreset(projectRoot: string) {
const presetPath = require.resolve(
'@react-native/babel-preset/package.json',
{
paths: [projectRoot],
}
);
const presetRequire = createRequire(presetPath);
const { version, dependencies } = presetRequire('./package.json');

return {
presetRequire,
dependencies: dependencies as Record<string, string> | undefined,
location: `@react-native/babel-preset@${version} (${presetPath})`,
};
}

/**
* Caches a lookup per project root: failed resolutions aren't cached by Node,
* and these run for every transformed file.
*/
function cacheByProjectRoot<T>(resolve: (projectRoot: string) => T) {
const cache = new Map<string, T>();
return (projectRoot: string): T => {
if (!cache.has(projectRoot)) {
cache.set(projectRoot, resolve(projectRoot));
}
return cache.get(projectRoot)!;
};
}

/**
* Resolves a dependency of the project's `@react-native/babel-preset`,
* e.g. a Babel plugin the preset ships with.
*/
export function resolveFromReactNativePreset(
projectRoot: string,
request: string
): string {
let presetLocation = projectRoot;

try {
const presetPath = require.resolve(
'@react-native/babel-preset/package.json',
{
paths: [projectRoot],
}
const { presetRequire, location } = resolvePreset(projectRoot);
presetLocation = location;
return presetRequire.resolve(request);
} catch (cause) {
throw Object.assign(
new Error(
`Failed to resolve '${request}' from ${presetLocation}. ` +
`Make sure '@react-native/babel-preset' is installed.`
),
{ cause }
);
}
}

const presetRequire = createRequire(presetPath);
const { version, dependencies } = presetRequire('./package.json');
/**
* Resolves `flow-enums-runtime`, which lowered Flow enums require.
*
* Only React Native depends on it, so with isolated installs (pnpm) it often
* can't be resolved from the project root or from the file using an enum.
*/
const resolveFlowEnumsRuntime = cacheByProjectRoot(
(projectRoot): string | null => {
try {
return require.resolve('flow-enums-runtime', { paths: [projectRoot] });
} catch {}

presetLocation = `@react-native/babel-preset@${version} (${presetPath})`;
try {
const reactNativePath = require.resolve('react-native/package.json', {
paths: [projectRoot],
});
return createRequire(reactNativePath).resolve('flow-enums-runtime');
} catch {
return null;
}
}
);

/**
* `babel-plugin-transform-flow-enums` from the project's React Native preset,
* or nothing when the preset isn't installed.
*
* Has to run ahead of the preset: the preset's `flow-strip-types` removes enum
* declarations before the preset's own enum plugin gets to them.
*/
export const getFlowEnumsPlugins = cacheByProjectRoot(
(projectRoot): PluginItem[] => {
let pluginPath: string;
try {
pluginPath = resolveFromReactNativePreset(
projectRoot,
'babel-plugin-transform-flow-enums'
);
} catch {
return [];
}

const runtimePath = resolveFlowEnumsRuntime(projectRoot);
// the plugin defaults to `require('flow-enums-runtime')`
if (!runtimePath) return [pluginPath];

return [
[
pluginPath,
{
getRuntime: (t: typeof types) =>
t.callExpression(t.identifier('require'), [
t.stringLiteral(runtimePath),
]),
},
],
];
}
);

/**
* Picks the React runtime that Flow `component` declarations are lowered for,
* based on the project's `react` version: React 19 passes `ref` as a prop,
* React 18 needs `forwardRef`. Defaults to React 19.
*/
export const resolveReactRuntimeTarget = cacheByProjectRoot(
(projectRoot): '18' | '19' => {
try {
const { version } = require(
require.resolve('react/package.json', { paths: [projectRoot] })
);
return Number.parseInt(version, 10) < 19 ? '18' : '19';
} catch {
return '19';
}
}
);

export function resolveReactNativeParser(projectRoot: string) {
let presetLocation = projectRoot;

try {
const { presetRequire, dependencies, location } =
resolvePreset(projectRoot);
presetLocation = location;

// A hoisted parser must not override the one declared by the preset.
if (dependencies?.['flow-parser']) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { transform } from '../babelLoader.js';
import { loadReactNativeParser } from '../utils.js';
import { parseReactNativeSource } from '../utils.js';

jest.mock('../utils.js', () => {
const actual = jest.requireActual('../utils.js');
const { parseSync } = require('@babel/core');
return {
...actual,
loadReactNativeParser: jest.fn(async () => ({
parse: (
parseReactNativeSource: jest.fn(
async (
src: string,
opts: { sourceType?: 'script' | 'module' | 'unambiguous' }
opts: { sourceType?: 'script' | 'module' | 'unambiguous' | null }
) =>
parseSync(src, {
sourceType: opts?.sourceType ?? 'unambiguous',
// the stand-in parser runs outside of a file context, so skip config lookup
filename: '/virtual/hermes-parser-stand-in.js',
babelrc: false,
configFile: false,
}),
})),
})
),
};
});

Expand All @@ -43,7 +43,7 @@ describe('babelLoader', () => {
baseTransformOptions('/virtual/plain.js')
);

expect(loadReactNativeParser).not.toHaveBeenCalled();
expect(parseReactNativeSource).not.toHaveBeenCalled();
});

it('uses hermes-parser for sources with an @flow pragma', async () => {
Expand All @@ -52,7 +52,7 @@ describe('babelLoader', () => {
baseTransformOptions('/virtual/flow.js')
);

expect(loadReactNativeParser).toHaveBeenCalled();
expect(parseReactNativeSource).toHaveBeenCalled();
});

it('uses hermes-parser for every source when flow is set to all', async () => {
Expand All @@ -62,7 +62,7 @@ describe('babelLoader', () => {
{ hermesParserOverrides: { flow: 'all' } }
);

expect(loadReactNativeParser).toHaveBeenCalled();
expect(parseReactNativeSource).toHaveBeenCalled();
});

it('skips hermes-parser for TypeScript sources', async () => {
Expand All @@ -71,7 +71,7 @@ describe('babelLoader', () => {
baseTransformOptions('/virtual/typescript.ts')
);

expect(loadReactNativeParser).not.toHaveBeenCalled();
expect(parseReactNativeSource).not.toHaveBeenCalled();
});
});

Expand Down
Loading
Loading