From 39beb8fca45c2a7c50e4945e9c1000e5c41ac0f7 Mon Sep 17 00:00:00 2001 From: Amir Saifutdinov Date: Fri, 9 Oct 2026 22:59:49 +0300 Subject: [PATCH 1/6] fix(repack): lower Flow component, hook, enum and match syntax in flow-loader flow-remove-types only erases type annotations, so on React Native 0.81+ flow-loader (and getJsTransformRules) emitted invalid JS for `component` / `hook` declarations, Flow enums and `match` expressions. Files using them now go through the project's React Native parser (hermes-parser or flow-parser) and @babel/plugin-transform-flow-strip-types; all other files still use flow-remove-types. Enums are lowered to flow-enums-runtime, resolved from the project or from react-native so it works with isolated installs. Source maps are emitted for lowered files. Also fix flow-loader crashing under webpack: getOptions() received the loader context as a schema. Part of #1422 --- .changeset/flow-loader-component-syntax.md | 5 + apps/tester-app/__tests__/bundle.test.ts | 4 + .../__tests__/configs/rspack.swc.config.mjs | 23 ++ .../src/helpers/resolveReactNativeParser.ts | 39 ++++ .../repack/src/loaders/babelLoader/utils.ts | 75 ++++++- .../src/loaders/flowLoader/flowLoader.ts | 58 ++++- .../repack/src/loaders/flowLoader/options.ts | 2 +- pnpm-lock.yaml | 3 + tests/integration/package.json | 1 + .../react-native-parser/component.js.txt | 5 + .../react-native-parser/enum.js.txt | 10 + .../react-native-parser/hook.js.txt | 5 + .../react-native-parser/match.js.txt | 9 + .../src/loaders/react-native-parser.test.ts | 207 +++++++++++++++++- website/src/latest/api/loaders/flow-loader.md | 4 +- .../api/utils/get-flow-transform-rules.md | 2 +- .../src/latest/docs/features/flow-support.md | 8 +- .../latest/docs/migration-guides/rspack.mdx | 2 +- 18 files changed, 448 insertions(+), 14 deletions(-) create mode 100644 .changeset/flow-loader-component-syntax.md create mode 100644 apps/tester-app/__tests__/configs/rspack.swc.config.mjs create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/component.js.txt create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/enum.js.txt create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/hook.js.txt create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/match.js.txt diff --git a/.changeset/flow-loader-component-syntax.md b/.changeset/flow-loader-component-syntax.md new file mode 100644 index 000000000..7f8d91188 --- /dev/null +++ b/.changeset/flow-loader-component-syntax.md @@ -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`) 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`. Also fix `flow-loader` crashing under webpack with `Maximum call stack size exceeded`. diff --git a/apps/tester-app/__tests__/bundle.test.ts b/apps/tester-app/__tests__/bundle.test.ts index 67d6e637f..95734ada9 100644 --- a/apps/tester-app/__tests__/bundle.test.ts +++ b/apps/tester-app/__tests__/bundle.test.ts @@ -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'); diff --git a/apps/tester-app/__tests__/configs/rspack.swc.config.mjs b/apps/tester-app/__tests__/configs/rspack.swc.config.mjs new file mode 100644 index 000000000..cb4f78c6c --- /dev/null +++ b/apps/tester-app/__tests__/configs/rspack.swc.config.mjs @@ -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, + }, + }; +}; diff --git a/packages/repack/src/helpers/resolveReactNativeParser.ts b/packages/repack/src/helpers/resolveReactNativeParser.ts index 140e8364d..01e19938f 100644 --- a/packages/repack/src/helpers/resolveReactNativeParser.ts +++ b/packages/repack/src/helpers/resolveReactNativeParser.ts @@ -1,5 +1,44 @@ import { createRequire } from 'node:module'; +/** + * 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 { + const presetPath = require.resolve( + '@react-native/babel-preset/package.json', + { + paths: [projectRoot], + } + ); + + return createRequire(presetPath).resolve(request); +} + +/** + * 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. + */ +export function resolveFlowEnumsRuntime(projectRoot: string): string | null { + try { + return require.resolve('flow-enums-runtime', { paths: [projectRoot] }); + } catch {} + + try { + const reactNativePath = require.resolve('react-native/package.json', { + paths: [projectRoot], + }); + return createRequire(reactNativePath).resolve('flow-enums-runtime'); + } catch { + return null; + } +} + export function resolveReactNativeParser(projectRoot: string) { let presetLocation = projectRoot; diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index 2f93b7431..2f44ee56f 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -1,6 +1,9 @@ import type { ParseResult } from '@babel/core'; import { importDefaultESM } from '../../helpers/index.js'; -import { resolveReactNativeParser } from '../../helpers/resolveReactNativeParser.js'; +import { + resolveFlowEnumsRuntime, + resolveReactNativeParser, +} from '../../helpers/resolveReactNativeParser.js'; interface ReactNativeParser { parse: ( @@ -10,6 +13,9 @@ interface ReactNativeParser { flow?: 'all' | 'detect'; reactRuntimeTarget: string; sourceType: 'script' | 'module' | 'unambiguous' | null | undefined; + transformOptions?: { + TransformEnumSyntax?: { enable: boolean; getRuntime?: () => unknown }; + }; } ) => ParseResult; } @@ -60,6 +66,73 @@ export async function loadReactNativeParser( } } +/** + * Parses a Flow source with the project's React Native parser into a Babel AST. + * + * The parser lowers Flow syntax that has no Babel plugin (e.g. `component` + * declarations) and Flow enums to `flow-enums-runtime`; the remaining type + * annotations are left for `@babel/plugin-transform-flow-strip-types`. + */ +export async function parseReactNativeSource( + src: string, + { + projectRoot, + flow, + }: { + projectRoot: string; + flow?: 'all' | 'detect'; + } +): Promise { + const parser = await loadReactNativeParser(projectRoot); + const runtimePath = resolveFlowEnumsRuntime(projectRoot); + + return parser.parse(src, { + babel: true, + flow, + reactRuntimeTarget: '19', + sourceType: 'unambiguous', + transformOptions: { + TransformEnumSyntax: { + enable: true, + // the parser defaults to `require('flow-enums-runtime')` + getRuntime: runtimePath ? () => requireCall(runtimePath) : undefined, + }, + }, + }); +} + +// ESTree `require(request)` in the shape of the parser's own AST builders +function requireCall(request: string) { + const etc = () => ({ + loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }, + range: [0, 0], + parent: null, + }); + + return { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'require', + optional: false, + typeAnnotation: null, + ...etc(), + }, + arguments: [ + { + type: 'Literal', + value: request, + raw: JSON.stringify(request), + literalType: 'string', + ...etc(), + }, + ], + typeArguments: null, + optional: false, + ...etc(), + }; +} + const IGNORED_REPACK_FILENAMES = [ 'IncludeModules.js', 'WebpackHMRClient.js', diff --git a/packages/repack/src/loaders/flowLoader/flowLoader.ts b/packages/repack/src/loaders/flowLoader/flowLoader.ts index 12c00250c..40d02d279 100644 --- a/packages/repack/src/loaders/flowLoader/flowLoader.ts +++ b/packages/repack/src/loaders/flowLoader/flowLoader.ts @@ -1,14 +1,68 @@ +import { transformFromAstSync } from '@babel/core'; import type { LoaderContext, RawSourceMap } from '@rspack/core'; import flowRemoveTypes from 'flow-remove-types'; -import { getOptions } from './options.js'; +import { resolveFromReactNativePreset } from '../../helpers/resolveReactNativeParser.js'; +import { parseReactNativeSource } from '../babelLoader/utils.js'; +import { type FlowLoaderOptions, getOptions } from './options.js'; export const raw = false; -export default function flowLoader(this: LoaderContext, source: string) { +// `flow-remove-types` leaves these constructs in place, producing invalid JS: +// `component` / `hook` declarations, enums and `match (...) {` (not `.match(`) +const UNSUPPORTED_FLOW_SYNTAX_REGEX = + /\b(?:(?:component|hook)\s+[\w$]+\s*[<(]|enum\s+[\w$]+\s*(?:of\s+[\w$]+\s*)?\{)|(?, + source: string, + options: FlowLoaderOptions +) { + const projectRoot = loaderContext.rootContext; + const ast = await parseReactNativeSource(source, { + projectRoot, + flow: options.all ? 'all' : 'detect', + }); + + const result = transformFromAstSync(ast, source, { + babelrc: false, + configFile: false, + filename: loaderContext.resourcePath, + // unlike `flow-remove-types`, lowering moves code around + sourceMaps: loaderContext.sourceMap, + sourceFileName: loaderContext.resourcePath, + plugins: [ + resolveFromReactNativePreset( + projectRoot, + '@babel/plugin-transform-flow-strip-types' + ), + ], + }); + + if (!result?.code) { + throw new Error( + `Failed to strip Flow types: ${loaderContext.resourcePath}` + ); + } + + return { code: result.code, map: result.map ?? undefined }; +} + +export default function flowLoader( + this: LoaderContext, + source: string +) { this.cacheable(); const callback = this.async(); const options = getOptions(this); + if (UNSUPPORTED_FLOW_SYNTAX_REGEX.test(source)) { + lowerFlowSyntax(this, source, options).then( + ({ code, map }) => callback(null, code, map as RawSourceMap | undefined), + (error) => callback(error) + ); + return; + } + const result = flowRemoveTypes(source, options); const sourceMap = options.pretty ? result.generateMap() : undefined; diff --git a/packages/repack/src/loaders/flowLoader/options.ts b/packages/repack/src/loaders/flowLoader/options.ts index 6b8114a03..8f4fd81c3 100644 --- a/packages/repack/src/loaders/flowLoader/options.ts +++ b/packages/repack/src/loaders/flowLoader/options.ts @@ -24,7 +24,7 @@ export const optionsSchema: Schema = { export function getOptions( loaderContext: LoaderContext ): FlowLoaderOptions { - const options = loaderContext.getOptions(loaderContext) || {}; + const options = loaderContext.getOptions() || {}; validate(optionsSchema, options, { name: 'repackFlowLoader' }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78c3a2359..dc4e55dba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1040,6 +1040,9 @@ importers: '@types/node': specifier: 'catalog:' version: 20.19.43 + flow-enums-runtime: + specifier: 0.0.6 + version: 0.0.6 hermes-parser: specifier: 0.36.0 version: 0.36.0 diff --git a/tests/integration/package.json b/tests/integration/package.json index c098876ce..a9179c987 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -22,6 +22,7 @@ "@rspack/core": "catalog:", "@swc/core": "^1.15.46", "@types/node": "catalog:", + "flow-enums-runtime": "0.0.6", "hermes-parser": "0.36.0", "memfs": "^4.11.1", "typescript": "catalog:", diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/component.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/component.js.txt new file mode 100644 index 000000000..79db30bd8 --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/component.js.txt @@ -0,0 +1,5 @@ +// @flow + +export component Greeting(name: string, excited?: boolean = false) { + return { greeting: `Hello ${name}${excited ? '!' : ''}` }; +} diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/enum.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/enum.js.txt new file mode 100644 index 000000000..9dfd7179f --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/enum.js.txt @@ -0,0 +1,10 @@ +// @flow + +export enum Status { + Active, + Paused, +} + +export function parseStatus(value: string): ?Status { + return Status.cast(value); +} diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/hook.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/hook.js.txt new file mode 100644 index 000000000..6a9531804 --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/hook.js.txt @@ -0,0 +1,5 @@ +// @flow + +export hook useDouble(value: number): number { + return value * 2; +} diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/match.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/match.js.txt new file mode 100644 index 000000000..6ed743c0a --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/match.js.txt @@ -0,0 +1,9 @@ +// @flow + +export function describeCount(count: number): string { + return match (Math.abs(count)) { + 0 => 'none', + 1 => 'one', + _ => 'many', + }; +} diff --git a/tests/integration/src/loaders/react-native-parser.test.ts b/tests/integration/src/loaders/react-native-parser.test.ts index c6e2026ce..bfbfd5f78 100644 --- a/tests/integration/src/loaders/react-native-parser.test.ts +++ b/tests/integration/src/loaders/react-native-parser.test.ts @@ -4,8 +4,11 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; -import { getCodegenTransformRules } from '@callstack/repack'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + getCodegenTransformRules, + getJsTransformRules, +} from '@callstack/repack'; +import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; import { compile, createCompiler } from '../helpers.js'; const require = createRequire(import.meta.url); @@ -16,6 +19,15 @@ const swcRoot = path.dirname(require.resolve('@swc/core/package.json')); const babelRuntimeRoot = path.dirname( require.resolve('@babel/runtime/package.json') ); +const flowEnumsRuntimeRoot = path.dirname( + require.resolve('flow-enums-runtime/package.json') +); +// `getJsTransformRules` emits external SWC helpers, apps install them +const swcHelpersRoot = path.dirname( + createRequire(require.resolve('@rspack/core/package.json')).resolve( + '@swc/helpers/package.json' + ) +); const fixtureRoot = fileURLToPath( new URL('./__fixtures__/react-native-parser/', import.meta.url) ); @@ -30,6 +42,38 @@ const sourceFiles = [ ]; const codegenFile = 'ParserNativeComponent.js'; +// Flow syntax that `flow-remove-types` alone cannot strip +const flowSyntaxFiles = ['component.js', 'hook.js', 'enum.js', 'match.js']; + +const FLOW_LIB_FILES: Record = { + 'package.json': '{ "name": "flow-lib", "main": "index.js" }', + 'index.js': [ + '// @flow', + "import type { Marker } from './marker';", + // unused value import, kept for its side effect + "import marker from './marker';", + '', + 'export enum Mode { Visible, Hidden }', + '', + 'export component Badge(label: string) {', + ' return { label };', + '}', + '', + 'export hook useMode(value: string): ?Mode {', + ' return Mode.cast(value);', + '}', + '', + 'export function describeCount(count: number): string {', + " return match (count) { 0 => 'none', _ => 'some' };", + '}', + ].join('\n'), + 'marker.js': [ + '// @flow', + 'export type Marker = string;', + "globalThis.flowLibMarker = 'loaded';", + 'export default null;', + ].join('\n'), +}; let workspaceRoot: string; const originalNodePath = process.env.NODE_PATH; @@ -48,7 +92,7 @@ function createFixtureProject( ) { fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); // Text fixtures keep Flow and standalone JSX out of repository checks. - for (const file of [...sourceFiles, codegenFile]) { + for (const file of [...sourceFiles, codegenFile, ...flowSyntaxFiles]) { fs.copyFileSync( path.join(fixtureRoot, `${file}.txt`), path.join(projectRoot, 'src', file) @@ -87,6 +131,34 @@ function createFixtureProject( linkPackage(projectRoot, '@callstack/repack', repackRoot); linkPackage(projectRoot, '@babel/runtime', babelRuntimeRoot); + // pnpm layout: lowered Flow enums need `flow-enums-runtime`, which only + // React Native depends on, so it can't be resolved from the project root + const reactNativeStoreRoot = path.join( + projectRoot, + 'node_modules/.pnpm/react-native@0.0.0-test' + ); + const reactNativeRoot = path.join( + reactNativeStoreRoot, + 'node_modules/react-native' + ); + fs.mkdirSync(reactNativeRoot, { recursive: true }); + fs.writeFileSync( + path.join(reactNativeRoot, 'package.json'), + JSON.stringify({ name: 'react-native', version: '0.0.0-test' }) + ); + linkPackage(reactNativeStoreRoot, 'flow-enums-runtime', flowEnumsRuntimeRoot); + linkPackage(projectRoot, 'react-native', reactNativeRoot); + + // A Flow-typed dependency, installed the way pnpm does + const flowLibRoot = path.join( + projectRoot, + 'node_modules/.pnpm/flow-lib@1.0.0/node_modules/flow-lib' + ); + fs.mkdirSync(flowLibRoot, { recursive: true }); + for (const [file, content] of Object.entries(FLOW_LIB_FILES)) { + fs.writeFileSync(path.join(flowLibRoot, file), content); + } + linkPackage(projectRoot, 'flow-lib', flowLibRoot); for (const name of [ '@react-native/babel-plugin-codegen', '@babel/plugin-syntax-typescript', @@ -95,6 +167,7 @@ function createFixtureProject( } // Webpack needs a resolvable SWC to exercise the SWC path instead of Babel. linkPackage(projectRoot, '@swc/core', swcRoot); + linkPackage(projectRoot, '@swc/helpers', swcHelpersRoot); } async function compileAndClose(config: Parameters[0]) { @@ -323,6 +396,134 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { }, }); }); + + function compileWithFlowLoader( + file: (typeof flowSyntaxFiles)[number], + devtool: 'source-map' | false = false + ) { + const projectRoot = path.join(workspaceRoot, preset); + return compileAndClose({ + context: projectRoot, + mode: 'development', + devtool, + entry: `./src/${file}`, + output: { + path: '/out', + library: { type: 'commonjs2' }, + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }, + module: { + rules: [ + { + test: /\.js$/, + use: { + loader: require.resolve('@callstack/repack/flow-loader'), + options: { all: true }, + }, + }, + ], + }, + }); + } + + async function bundleWithFlowLoader(file: (typeof flowSyntaxFiles)[number]) { + const { code } = await compileWithFlowLoader(file); + return executeBundle(code); + } + + it('maps lowered Flow syntax back to the original source', async () => { + const { volume } = await compileWithFlowLoader( + 'component.js', + 'source-map' + ); + + const sourceMap = JSON.parse( + volume.readFileSync('/out/main.js.map', 'utf-8') as string + ); + const sourcePath = path.join(workspaceRoot, preset, 'src/component.js'); + const index = sourceMap.sources.indexOf(sourcePath); + expect(index).toBeGreaterThanOrEqual(0); + expect(sourceMap.sourcesContent[index]).toBe( + fs.readFileSync(sourcePath, 'utf-8') + ); + }); + + it('lowers Flow component syntax with flow-loader', async () => { + const { Greeting } = (await bundleWithFlowLoader('component.js')) as { + Greeting: (props: { name: string; excited?: boolean }) => unknown; + }; + expect(Greeting({ name: 'Flow' })).toEqual({ greeting: 'Hello Flow' }); + expect(Greeting({ name: 'Flow', excited: true })).toEqual({ + greeting: 'Hello Flow!', + }); + }); + + it('lowers Flow hook syntax with flow-loader', async () => { + const { useDouble } = (await bundleWithFlowLoader('hook.js')) as { + useDouble: (value: number) => number; + }; + expect(useDouble(21)).toBe(42); + }); + + it('lowers Flow enums to flow-enums-runtime with flow-loader', async () => { + const { Status, parseStatus } = (await bundleWithFlowLoader('enum.js')) as { + Status: { Active: unknown; members: () => Iterable }; + parseStatus: (value: string) => unknown; + }; + expect(parseStatus('Active')).toBe(Status.Active); + expect(parseStatus('Unknown')).toBeUndefined(); + expect([...Status.members()]).toEqual(['Active', 'Paused']); + }); + + it('lowers Flow match expressions with flow-loader', async () => { + const { describeCount } = (await bundleWithFlowLoader('match.js')) as { + describeCount: (count: number) => string; + }; + expect([0, -1, 5].map(describeCount)).toEqual(['none', 'one', 'many']); + }); + + // builtin:swc-loader only exists in Rspack + it.runIf(inject('bundlerType') === 'rspack')( + 'compiles a Flow-typed dependency with getJsTransformRules', + async () => { + const projectRoot = path.join(workspaceRoot, preset); + fs.writeFileSync( + path.join(projectRoot, 'src/flow-lib-app.js'), + [ + "import { Badge, Mode, describeCount, useMode } from 'flow-lib';", + 'export const result = {', + " badge: Badge({ label: 'Flow' }),", + " mode: useMode('Hidden') === Mode.Hidden,", + ' count: describeCount(0),', + ' marker: globalThis.flowLibMarker,', + '};', + ].join('\n') + ); + + const { code } = await compileAndClose({ + context: projectRoot, + mode: 'development', + devtool: false, + entry: './src/flow-lib-app.js', + output: { path: '/out', library: { type: 'commonjs2' } }, + module: { + rules: getJsTransformRules({ + flow: { include: ['flow-lib'] }, + codegen: { enabled: false }, + }), + }, + }); + + expect(executeBundle(code)).toEqual({ + result: { + badge: { label: 'Flow' }, + mode: true, + count: 'none', + marker: 'loaded', + }, + }); + } + ); }); describe.each(['unsupported-preset', 'missing-flow-parser'])( diff --git a/website/src/latest/api/loaders/flow-loader.md b/website/src/latest/api/loaders/flow-loader.md index f6801eee4..5714ac519 100644 --- a/website/src/latest/api/loaders/flow-loader.md +++ b/website/src/latest/api/loaders/flow-loader.md @@ -3,7 +3,9 @@ The `FlowLoader` removes Flow type annotations from JavaScript files, ensuring they can be processed by loaders that do not support Flow syntax. It should be positioned before other loaders (e.g. `builtin:swc-loader`) to prevent parsing errors when encountering Flow-specific code. :::details -This loader uses `flow-remove-types` under the hood. You can learn more about it [here](https://github.com/facebook/flow/tree/main/packages/flow-remove-types). +Most files go through `flow-remove-types`. You can learn more about it [here](https://github.com/facebook/flow/tree/main/packages/flow-remove-types). + +`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. ::: ## Options diff --git a/website/src/latest/api/utils/get-flow-transform-rules.md b/website/src/latest/api/utils/get-flow-transform-rules.md index 638783e10..58173e153 100644 --- a/website/src/latest/api/utils/get-flow-transform-rules.md +++ b/website/src/latest/api/utils/get-flow-transform-rules.md @@ -1,6 +1,6 @@ # getFlowTransformRules -A helper function that generates `module.rules` configuration for handling Flow type annotations in JavaScript files. The rules use `@callstack/repack/flow-loader` to remove Flow types from the code before other processing. +A helper function that generates `module.rules` configuration for handling Flow type annotations in JavaScript files. The rules use `@callstack/repack/flow-loader` to remove Flow types from the code before other processing, including Flow `component` and `hook` declarations, enums and `match` expressions used by React Native 0.81+ (see [how `flow-loader` handles them](/api/loaders/flow-loader)). :::info This helper function is a part of [`getJsTransformRules`](/api/utils/get-js-transform-rules). diff --git a/website/src/latest/docs/features/flow-support.md b/website/src/latest/docs/features/flow-support.md index b63f0d1b8..5502eb9f2 100644 --- a/website/src/latest/docs/features/flow-support.md +++ b/website/src/latest/docs/features/flow-support.md @@ -1,7 +1,7 @@ # Flow support :::note Rspack users -Since SWC doesn't currently support Flow, we use a dedicated loader to strip Flow types in Rspack. With webpack, Flow support is implemented using the same set of Babel plugins as Metro. +Since Rspack's built-in SWC loader can't parse Flow yet, we use a dedicated loader to strip Flow types in Rspack. With webpack, Flow support is implemented using the same set of Babel plugins as Metro. ::: Re.Pack 5 includes support for Flow language via dedicated loaders that remove Flow type annotations from the code. @@ -16,7 +16,7 @@ module.exports = { rules: [ { test: /\.jsx?$/, - exclude: /node_modules/ + exclude: /node_modules/, use: { loader: "@callstack/repack/flow-loader", options: { all: true }, @@ -33,7 +33,7 @@ To see the full list of options available, please refer to the [flow-loader docu ## Usage with libraries Re.Pack also includes list of popular React Native libraries that ships with Flow type annotations and automatically strips annotations from them. -Here's the list of libraries that are [supported](https://github.com/callstack/repack/blob/07b2e2059487f0b6962b05016e7f1453ba35c379/packages/repack/src/rules/flowTypedModulesLoadingRules.ts#L12). +Here's the list of libraries that are [supported](https://github.com/callstack/repack/blob/main/packages/repack/src/utils/getFlowTransformRules.ts). :::info Contributing If you're using a Flow-typed library that's not on our list, please consider opening an issue or submitting a PR! @@ -42,7 +42,7 @@ If you're using a Flow-typed library that's not on our list, please consider ope However if you are using a library that is not in the list, you can add it to the list by using the following configuration: ```js title=rspack.config.cjs -import { getModulePaths } from '@callstack/repack/utils'; +import { getModulePaths } from '@callstack/repack'; module.exports = { module: { diff --git a/website/src/latest/docs/migration-guides/rspack.mdx b/website/src/latest/docs/migration-guides/rspack.mdx index 3883b4e29..a76970f03 100644 --- a/website/src/latest/docs/migration-guides/rspack.mdx +++ b/website/src/latest/docs/migration-guides/rspack.mdx @@ -106,7 +106,7 @@ The `getJsTransformRules()` function returns the default rules for transforming #### Configure Flow support (if needed) -If your project uses [Flow](https://flow.org/) for type checking, you'll need to add a dedicated loader since SWC (used by Rspack) doesn't support Flow natively. Re.Pack provides a `@callstack/repack/flow-loader` to strip Flow type annotations from your code. Add the following rule to your module configuration: +If your project uses [Flow](https://flow.org/) for type checking, you'll need to add a dedicated loader since Rspack's built-in SWC loader can't parse Flow yet. Re.Pack provides a `@callstack/repack/flow-loader` to strip Flow type annotations from your code. Add the following rule to your module configuration: ```diff title="rspack.config.cjs" module.exports = { From 1702fe284eeafcc4e69a6b1d9019876ac9842654 Mon Sep 17 00:00:00 2001 From: Amir Saifutdinov Date: Fri, 9 Oct 2026 23:00:58 +0300 Subject: [PATCH 2/6] fix(repack): lower Flow components for the project's React version Flow `component` declarations with a `ref` are lowered to `forwardRef` on React 18 and pass `ref` as a prop on React 19. Pick the target from the project's `react` version instead of always assuming React 19. Part of #1422 --- .changeset/flow-loader-component-syntax.md | 2 +- .../src/helpers/resolveReactNativeParser.ts | 16 ++++ .../repack/src/loaders/babelLoader/utils.ts | 3 +- .../react-native-parser/ref-component.js.txt | 6 ++ .../src/loaders/react-native-parser.test.ts | 80 ++++++++++++++++--- website/src/latest/api/loaders/flow-loader.md | 2 +- 6 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/ref-component.js.txt diff --git a/.changeset/flow-loader-component-syntax.md b/.changeset/flow-loader-component-syntax.md index 7f8d91188..2f80e88b2 100644 --- a/.changeset/flow-loader-component-syntax.md +++ b/.changeset/flow-loader-component-syntax.md @@ -2,4 +2,4 @@ "@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`) 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`. Also fix `flow-loader` crashing under webpack with `Maximum call stack size exceeded`. +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`) 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`. diff --git a/packages/repack/src/helpers/resolveReactNativeParser.ts b/packages/repack/src/helpers/resolveReactNativeParser.ts index 01e19938f..6d065668b 100644 --- a/packages/repack/src/helpers/resolveReactNativeParser.ts +++ b/packages/repack/src/helpers/resolveReactNativeParser.ts @@ -39,6 +39,22 @@ export function resolveFlowEnumsRuntime(projectRoot: string): string | null { } } +/** + * 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 function resolveReactRuntimeTarget(projectRoot: string): '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; diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index 2f44ee56f..23ff8d78c 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -3,6 +3,7 @@ import { importDefaultESM } from '../../helpers/index.js'; import { resolveFlowEnumsRuntime, resolveReactNativeParser, + resolveReactRuntimeTarget, } from '../../helpers/resolveReactNativeParser.js'; interface ReactNativeParser { @@ -89,7 +90,7 @@ export async function parseReactNativeSource( return parser.parse(src, { babel: true, flow, - reactRuntimeTarget: '19', + reactRuntimeTarget: resolveReactRuntimeTarget(projectRoot), sourceType: 'unambiguous', transformOptions: { TransformEnumSyntax: { diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/ref-component.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/ref-component.js.txt new file mode 100644 index 000000000..f147f0e8d --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/ref-component.js.txt @@ -0,0 +1,6 @@ +// @flow +import * as React from 'react'; + +export component Field(label: string, ref: React.RefSetter) { + return { label, ref }; +} diff --git a/tests/integration/src/loaders/react-native-parser.test.ts b/tests/integration/src/loaders/react-native-parser.test.ts index bfbfd5f78..f5e7f1ad7 100644 --- a/tests/integration/src/loaders/react-native-parser.test.ts +++ b/tests/integration/src/loaders/react-native-parser.test.ts @@ -43,7 +43,13 @@ const sourceFiles = [ const codegenFile = 'ParserNativeComponent.js'; // Flow syntax that `flow-remove-types` alone cannot strip -const flowSyntaxFiles = ['component.js', 'hook.js', 'enum.js', 'match.js']; +const flowSyntaxFiles = [ + 'component.js', + 'ref-component.js', + 'hook.js', + 'enum.js', + 'match.js', +]; const FLOW_LIB_FILES: Record = { 'package.json': '{ "name": "flow-lib", "main": "index.js" }', @@ -86,6 +92,19 @@ function linkPackage(root: string, name: string, packageRoot: string) { fs.symlinkSync(packageRoot, destination, 'junction'); } +function writeReactPackage(root: string, version: string) { + const reactRoot = path.join(root, 'node_modules/react'); + fs.mkdirSync(reactRoot, { recursive: true }); + fs.writeFileSync( + path.join(reactRoot, 'package.json'), + JSON.stringify({ name: 'react', version, main: 'index.js' }) + ); + fs.writeFileSync( + path.join(reactRoot, 'index.js'), + "exports.forwardRef = (render) => ({ $$typeof: 'react.forward_ref', render });" + ); +} + function createFixtureProject( projectRoot: string, preset: (typeof presets)[number] @@ -131,6 +150,18 @@ function createFixtureProject( linkPackage(projectRoot, '@callstack/repack', repackRoot); linkPackage(projectRoot, '@babel/runtime', babelRuntimeRoot); + + // Flow `component` lowering depends on the React version: the project uses + // React 19, a nested project overrides it with React 18 + writeReactPackage(projectRoot, '19.2.0'); + const react18Root = path.join(projectRoot, 'react18'); + fs.mkdirSync(path.join(react18Root, 'src'), { recursive: true }); + fs.copyFileSync( + path.join(fixtureRoot, 'ref-component.js.txt'), + path.join(react18Root, 'src/ref-component.js') + ); + writeReactPackage(react18Root, '18.3.1'); + // pnpm layout: lowered Flow enums need `flow-enums-runtime`, which only // React Native depends on, so it can't be resolved from the project root const reactNativeStoreRoot = path.join( @@ -399,9 +430,12 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { function compileWithFlowLoader( file: (typeof flowSyntaxFiles)[number], - devtool: 'source-map' | false = false + { + devtool = false, + project = '.', + }: { devtool?: 'source-map' | false; project?: '.' | 'react18' } = {} ) { - const projectRoot = path.join(workspaceRoot, preset); + const projectRoot = path.join(workspaceRoot, preset, project); return compileAndClose({ context: projectRoot, mode: 'development', @@ -426,16 +460,18 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { }); } - async function bundleWithFlowLoader(file: (typeof flowSyntaxFiles)[number]) { - const { code } = await compileWithFlowLoader(file); + async function bundleWithFlowLoader( + file: (typeof flowSyntaxFiles)[number], + project?: '.' | 'react18' + ) { + const { code } = await compileWithFlowLoader(file, { project }); return executeBundle(code); } it('maps lowered Flow syntax back to the original source', async () => { - const { volume } = await compileWithFlowLoader( - 'component.js', - 'source-map' - ); + const { volume } = await compileWithFlowLoader('component.js', { + devtool: 'source-map', + }); const sourceMap = JSON.parse( volume.readFileSync('/out/main.js.map', 'utf-8') as string @@ -458,6 +494,32 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { }); }); + it('passes component refs as a prop on React 19', async () => { + const { Field } = (await bundleWithFlowLoader('ref-component.js')) as { + Field: (props: { label: string; ref: unknown }) => unknown; + }; + const ref = () => {}; + expect(Field({ label: 'Name', ref })).toEqual({ label: 'Name', ref }); + }); + + it('wraps components with refs in forwardRef on React 18', async () => { + const { Field } = (await bundleWithFlowLoader( + 'ref-component.js', + 'react18' + )) as { + Field: { + $$typeof: string; + render: (props: { label: string }, ref: unknown) => unknown; + }; + }; + const ref = () => {}; + expect(Field.$$typeof).toBe('react.forward_ref'); + expect(Field.render({ label: 'Name' }, ref)).toEqual({ + label: 'Name', + ref, + }); + }); + it('lowers Flow hook syntax with flow-loader', async () => { const { useDouble } = (await bundleWithFlowLoader('hook.js')) as { useDouble: (value: number) => number; diff --git a/website/src/latest/api/loaders/flow-loader.md b/website/src/latest/api/loaders/flow-loader.md index 5714ac519..35a3e4af7 100644 --- a/website/src/latest/api/loaders/flow-loader.md +++ b/website/src/latest/api/loaders/flow-loader.md @@ -5,7 +5,7 @@ The `FlowLoader` removes Flow type annotations from JavaScript files, ensuring t :::details Most files go through `flow-remove-types`. You can learn more about it [here](https://github.com/facebook/flow/tree/main/packages/flow-remove-types). -`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. +`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. `component` declarations are lowered for the React version installed in your project: `ref` becomes a regular prop on React 19 and `forwardRef` is used on React 18. ::: ## Options From 19ea4273bc790d6e16ed4e9d865b50af0a9345dd Mon Sep 17 00:00:00 2001 From: Amir Saifutdinov Date: Fri, 9 Oct 2026 23:12:17 +0300 Subject: [PATCH 3/6] fix(repack): keep Flow enums in babel-loader and babel-swc-loader @react-native/babel-preset strips Flow enum declarations before its enum plugin runs, so references such as React Native's VirtualViewMode.cast() were undefined at runtime. Parse @flow sources with the shared parseReactNativeSource helper, which lowers enums to flow-enums-runtime and picks the component lowering target from the project's React version (hermesParserOverrides still take precedence). Related to #1422 --- .changeset/babel-loader-flow-enums.md | 5 ++ .../babelLoader/__tests__/babelLoader.test.ts | 20 ++--- .../src/loaders/babelLoader/babelLoader.ts | 18 ++--- .../repack/src/loaders/babelLoader/utils.ts | 15 +++- .../src/loaders/react-native-parser.test.ts | 76 +++++++++++++++++++ .../src/latest/api/loaders/babel-loader.md | 4 +- .../latest/api/loaders/babel-swc-loader.md | 4 +- 7 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 .changeset/babel-loader-flow-enums.md diff --git a/.changeset/babel-loader-flow-enums.md b/.changeset/babel-loader-flow-enums.md new file mode 100644 index 000000000..ae00b06f1 --- /dev/null +++ b/.changeset/babel-loader-flow-enums.md @@ -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. The React Native parser now lowers enums to `flow-enums-runtime`, resolved from the project or from `react-native`. Flow `component` declarations are also lowered for the project's React version instead of always targeting React 19 (`hermesParserOverrides.reactRuntimeTarget` still takes precedence). diff --git a/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts b/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts index 664e80d99..b3e5c637a 100644 --- a/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts +++ b/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts @@ -1,15 +1,15 @@ 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', @@ -17,8 +17,8 @@ jest.mock('../utils.js', () => { filename: '/virtual/hermes-parser-stand-in.js', babelrc: false, configFile: false, - }), - })), + }) + ), }; }); @@ -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 () => { @@ -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 () => { @@ -62,7 +62,7 @@ describe('babelLoader', () => { { hermesParserOverrides: { flow: 'all' } } ); - expect(loadReactNativeParser).toHaveBeenCalled(); + expect(parseReactNativeSource).toHaveBeenCalled(); }); it('skips hermes-parser for TypeScript sources', async () => { @@ -71,7 +71,7 @@ describe('babelLoader', () => { baseTransformOptions('/virtual/typescript.ts') ); - expect(loadReactNativeParser).not.toHaveBeenCalled(); + expect(parseReactNativeSource).not.toHaveBeenCalled(); }); }); diff --git a/packages/repack/src/loaders/babelLoader/babelLoader.ts b/packages/repack/src/loaders/babelLoader/babelLoader.ts index 0725bf861..13ea43a56 100644 --- a/packages/repack/src/loaders/babelLoader/babelLoader.ts +++ b/packages/repack/src/loaders/babelLoader/babelLoader.ts @@ -16,7 +16,7 @@ import { isIgnoredRepackDeepImport, isTSXSource, isTypeScriptSource, - loadReactNativeParser, + parseReactNativeSource, shouldUseReactNativeParser, } from './utils.js'; @@ -92,17 +92,13 @@ export const transform = async ( let sourceAst: ParseResult | null; if (needsReactNativeParser) { - // load the parser dynamically to match the version from preset - const parser = await loadReactNativeParser( - projectRoot, - customOptions?.hermesParserPath - ); - - sourceAst = parser.parse(src, { - babel: true, - reactRuntimeTarget: '19', + // the parser comes from the preset, it also lowers Flow enums that the + // preset would otherwise strip before its enum plugin runs + sourceAst = await parseReactNativeSource(src, { + projectRoot: projectRoot ?? process.cwd(), + parserPath: customOptions?.hermesParserPath, sourceType: babelConfig.sourceType, - ...customOptions?.hermesParserOverrides, + overrides: customOptions?.hermesParserOverrides, }); } else if (isTypeScript) { sourceAst = parseSync(src, babelConfig); diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index 23ff8d78c..ae47cb973 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -5,6 +5,7 @@ import { resolveReactNativeParser, resolveReactRuntimeTarget, } from '../../helpers/resolveReactNativeParser.js'; +import type { HermesParserOverrides } from './options.js'; interface ReactNativeParser { parse: ( @@ -78,20 +79,28 @@ export async function parseReactNativeSource( src: string, { projectRoot, + parserPath, flow, + sourceType = 'unambiguous', + overrides, }: { projectRoot: string; + parserPath?: string; flow?: 'all' | 'detect'; + sourceType?: 'script' | 'module' | 'unambiguous' | null; + overrides?: HermesParserOverrides; } ): Promise { - const parser = await loadReactNativeParser(projectRoot); + const parser = await loadReactNativeParser(projectRoot, parserPath); const runtimePath = resolveFlowEnumsRuntime(projectRoot); return parser.parse(src, { babel: true, - flow, + // the parser rejects `flow: undefined` + ...(flow && { flow }), reactRuntimeTarget: resolveReactRuntimeTarget(projectRoot), - sourceType: 'unambiguous', + sourceType, + ...overrides, transformOptions: { TransformEnumSyntax: { enable: true, diff --git a/tests/integration/src/loaders/react-native-parser.test.ts b/tests/integration/src/loaders/react-native-parser.test.ts index f5e7f1ad7..c81852b18 100644 --- a/tests/integration/src/loaders/react-native-parser.test.ts +++ b/tests/integration/src/loaders/react-native-parser.test.ts @@ -161,6 +161,10 @@ function createFixtureProject( path.join(react18Root, 'src/ref-component.js') ); writeReactPackage(react18Root, '18.3.1'); + fs.copyFileSync( + path.join(projectRoot, 'babel.config.cjs'), + path.join(react18Root, 'babel.config.cjs') + ); // pnpm layout: lowered Flow enums need `flow-enums-runtime`, which only // React Native depends on, so it can't be resolved from the project root @@ -494,6 +498,78 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { }); }); + // The preset strips Flow enums before its enum plugin runs, so the React + // Native parser has to lower them itself + async function bundleWithBabelLoader( + loader: (typeof loaders)[number], + file: (typeof flowSyntaxFiles)[number], + project: '.' | 'react18' = '.' + ) { + const projectRoot = path.join(workspaceRoot, preset, project); + const { code } = await compileAndClose({ + context: projectRoot, + mode: 'development', + devtool: false, + entry: `./src/${file}`, + output: { path: '/out', library: { type: 'commonjs2' } }, + module: { + rules: [ + { + test: /\.js$/, + // keep SWC's own ESM helpers out of the SWC transform + exclude: /node_modules/, + use: { + loader: require.resolve(`@callstack/repack/${loader}`), + options: + loader === 'babel-loader' + ? { root: projectRoot } + : { + hideParallelModeWarning: true, + babelOverrides: { cwd: projectRoot }, + }, + }, + }, + ], + }, + }); + + return executeBundle(code); + } + + it.each(loaders)('keeps Flow enums working with %s', async (loader) => { + const { Status, parseStatus } = (await bundleWithBabelLoader( + loader, + 'enum.js' + )) as { + Status: { Active: unknown; members: () => Iterable }; + parseStatus: (value: string) => unknown; + }; + expect(parseStatus('Active')).toBe(Status.Active); + expect([...Status.members()]).toEqual(['Active', 'Paused']); + }); + + it.each(loaders)( + 'wraps components with refs in forwardRef on React 18 with %s', + async (loader) => { + const { Field } = (await bundleWithBabelLoader( + loader, + 'ref-component.js', + 'react18' + )) as { + Field: { + $$typeof: string; + render: (props: { label: string }, ref: unknown) => unknown; + }; + }; + const ref = () => {}; + expect(Field.$$typeof).toBe('react.forward_ref'); + expect(Field.render({ label: 'Name' }, ref)).toEqual({ + label: 'Name', + ref, + }); + } + ); + it('passes component refs as a prop on React 19', async () => { const { Field } = (await bundleWithFlowLoader('ref-component.js')) as { Field: (props: { label: string; ref: unknown }) => unknown; diff --git a/website/src/latest/api/loaders/babel-loader.md b/website/src/latest/api/loaders/babel-loader.md index c45eb1246..6eb1d1e92 100644 --- a/website/src/latest/api/loaders/babel-loader.md +++ b/website/src/latest/api/loaders/babel-loader.md @@ -34,9 +34,9 @@ Optional path to the React Native parser module. This overrides automatic parser ### hermesParserOverrides - Type: `HermesParserOverrides` -- Default: `{ babel: true, reactRuntimeTarget: '19' }` +- Default: `{ babel: true, reactRuntimeTarget }`, where `reactRuntimeTarget` follows the project's `react` version (`'18'` before React 19, `'19'` otherwise) -Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. +Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. Flow enums are lowered by the parser to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`, because `@react-native/babel-preset` strips them before its enum plugin runs. ```ts type HermesParserOverrides = { diff --git a/website/src/latest/api/loaders/babel-swc-loader.md b/website/src/latest/api/loaders/babel-swc-loader.md index e42f66b0e..e04938576 100644 --- a/website/src/latest/api/loaders/babel-swc-loader.md +++ b/website/src/latest/api/loaders/babel-swc-loader.md @@ -83,9 +83,9 @@ Optional path to the React Native parser module. This overrides automatic parser ### hermesParserOverrides - Type: `HermesParserOverrides` -- Default: `{ babel: true, reactRuntimeTarget: '19' }` +- Default: `{ babel: true, reactRuntimeTarget }`, where `reactRuntimeTarget` follows the project's `react` version (`'18'` before React 19, `'19'` otherwise) -Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. +Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. Flow enums are lowered by the parser to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`, because `@react-native/babel-preset` strips them before its enum plugin runs. ```ts type HermesParserOverrides = { From ea31d9babbccc7377dbffc5c061ca6e1e46d6e7d Mon Sep 17 00:00:00 2001 From: Amir Saifutdinov Date: Fri, 9 Oct 2026 23:44:17 +0300 Subject: [PATCH 4/6] fix(repack): address review findings in Flow lowering - detect multi-line `match (...)` in flow-loader's prefilter and cover the routing with unit tests - strip class fields of lowered files like flow-remove-types does: allow `declare` fields and honour `ignoreUninitializedFields` - require @babel/core only when a file needs lowering - cache flow-enums-runtime and React version lookups per project root - share the preset lookup and wrap its resolution errors - pin unused import preservation in a file that is not lowered Part of #1422 --- .../src/helpers/resolveReactNativeParser.ts | 112 ++++++++++++------ .../repack/src/loaders/babelLoader/utils.ts | 20 ++-- .../flowLoader/__tests__/flowLoader.test.ts | 28 +++++ .../src/loaders/flowLoader/flowLoader.ts | 68 +++++++---- .../react-native-parser/class-fields.js.txt | 11 ++ .../src/loaders/react-native-parser.test.ts | 47 ++++++-- website/src/latest/api/loaders/flow-loader.md | 2 +- 7 files changed, 212 insertions(+), 76 deletions(-) create mode 100644 packages/repack/src/loaders/flowLoader/__tests__/flowLoader.test.ts create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/class-fields.js.txt diff --git a/packages/repack/src/helpers/resolveReactNativeParser.ts b/packages/repack/src/helpers/resolveReactNativeParser.ts index 6d065668b..9db54f3fc 100644 --- a/packages/repack/src/helpers/resolveReactNativeParser.ts +++ b/packages/repack/src/helpers/resolveReactNativeParser.ts @@ -1,5 +1,36 @@ import { createRequire } from 'node:module'; +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 | 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(resolve: (projectRoot: string) => T) { + const cache = new Map(); + 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. @@ -8,14 +39,21 @@ export function resolveFromReactNativePreset( projectRoot: string, request: string ): string { - const presetPath = require.resolve( - '@react-native/babel-preset/package.json', - { - paths: [projectRoot], - } - ); + let presetLocation = projectRoot; - return createRequire(presetPath).resolve(request); + try { + 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 } + ); + } } /** @@ -24,52 +62,48 @@ export function resolveFromReactNativePreset( * 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. */ -export function resolveFlowEnumsRuntime(projectRoot: string): string | null { - try { - return require.resolve('flow-enums-runtime', { paths: [projectRoot] }); - } catch {} +export const resolveFlowEnumsRuntime = cacheByProjectRoot( + (projectRoot): string | null => { + try { + return require.resolve('flow-enums-runtime', { paths: [projectRoot] }); + } catch {} - try { - const reactNativePath = require.resolve('react-native/package.json', { - paths: [projectRoot], - }); - return createRequire(reactNativePath).resolve('flow-enums-runtime'); - } catch { - return null; + try { + const reactNativePath = require.resolve('react-native/package.json', { + paths: [projectRoot], + }); + return createRequire(reactNativePath).resolve('flow-enums-runtime'); + } catch { + return null; + } } -} +); /** * 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 function resolveReactRuntimeTarget(projectRoot: string): '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 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 presetPath = require.resolve( - '@react-native/babel-preset/package.json', - { - paths: [projectRoot], - } - ); - - const presetRequire = createRequire(presetPath); - const { version, dependencies } = presetRequire('./package.json'); - - presetLocation = `@react-native/babel-preset@${version} (${presetPath})`; + const { presetRequire, dependencies, location } = + resolvePreset(projectRoot); + presetLocation = location; // A hoisted parser must not override the one declared by the preset. if (dependencies?.['flow-parser']) { diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index ae47cb973..8ea2c3858 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -12,9 +12,11 @@ interface ReactNativeParser { src: string, opts: { babel: boolean; - flow?: 'all' | 'detect'; - reactRuntimeTarget: string; - sourceType: 'script' | 'module' | 'unambiguous' | null | undefined; + flow?: HermesParserOverrides['flow']; + reactRuntimeTarget: NonNullable< + HermesParserOverrides['reactRuntimeTarget'] + >; + sourceType: HermesParserOverrides['sourceType'] | null; transformOptions?: { TransformEnumSyntax?: { enable: boolean; getRuntime?: () => unknown }; }; @@ -86,8 +88,8 @@ export async function parseReactNativeSource( }: { projectRoot: string; parserPath?: string; - flow?: 'all' | 'detect'; - sourceType?: 'script' | 'module' | 'unambiguous' | null; + flow?: HermesParserOverrides['flow']; + sourceType?: HermesParserOverrides['sourceType'] | null; overrides?: HermesParserOverrides; } ): Promise { @@ -113,7 +115,7 @@ export async function parseReactNativeSource( // ESTree `require(request)` in the shape of the parser's own AST builders function requireCall(request: string) { - const etc = () => ({ + const syntheticLocation = () => ({ loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }, range: [0, 0], parent: null, @@ -126,7 +128,7 @@ function requireCall(request: string) { name: 'require', optional: false, typeAnnotation: null, - ...etc(), + ...syntheticLocation(), }, arguments: [ { @@ -134,12 +136,12 @@ function requireCall(request: string) { value: request, raw: JSON.stringify(request), literalType: 'string', - ...etc(), + ...syntheticLocation(), }, ], typeArguments: null, optional: false, - ...etc(), + ...syntheticLocation(), }; } diff --git a/packages/repack/src/loaders/flowLoader/__tests__/flowLoader.test.ts b/packages/repack/src/loaders/flowLoader/__tests__/flowLoader.test.ts new file mode 100644 index 000000000..6f27757a7 --- /dev/null +++ b/packages/repack/src/loaders/flowLoader/__tests__/flowLoader.test.ts @@ -0,0 +1,28 @@ +import { needsFlowLowering } from '../flowLoader.js'; + +describe('flowLoader', () => { + describe('needsFlowLowering', () => { + it.each([ + ['component declaration', 'export component View(ref: Ref) {}'], + ['default component', 'export default component Foo(x: T) {}'], + ['hook declaration', 'hook useFoo(): number { return 1; }'], + ['enum', 'export enum Status { Active, Paused }'], + ['typed enum', 'enum Mode of string { A = "a" }'], + ['match expression', "const s = match (x) { 0 => 'none', _ => 'some' };"], + ['match with a call', 'return match (Math.abs(x)) { _ => 1 };'], + ['multi-line match', 'return match (\n props.value\n) {\n _ => 1,\n};'], + ])('sends a %s to the React Native parser', (_, source) => { + expect(needsFlowLowering(source)).toBe(true); + }); + + it.each([ + ['type annotations', 'const x: number = 1;\ntype T = { a: string };'], + ['component types', 'type C = component(ref: Ref);'], + ['String#match', 'if (value.match(/re/)) { run(); }'], + ['a `components` identifier', 'const components = [];'], + ['an `enums` identifier', 'const enums = {};'], + ])('keeps %s on flow-remove-types', (_, source) => { + expect(needsFlowLowering(source)).toBe(false); + }); + }); +}); diff --git a/packages/repack/src/loaders/flowLoader/flowLoader.ts b/packages/repack/src/loaders/flowLoader/flowLoader.ts index 40d02d279..aae1319d3 100644 --- a/packages/repack/src/loaders/flowLoader/flowLoader.ts +++ b/packages/repack/src/loaders/flowLoader/flowLoader.ts @@ -1,4 +1,4 @@ -import { transformFromAstSync } from '@babel/core'; +import type { PluginObj } from '@babel/core'; import type { LoaderContext, RawSourceMap } from '@rspack/core'; import flowRemoveTypes from 'flow-remove-types'; import { resolveFromReactNativePreset } from '../../helpers/resolveReactNativeParser.js'; @@ -8,22 +8,42 @@ import { type FlowLoaderOptions, getOptions } from './options.js'; export const raw = false; // `flow-remove-types` leaves these constructs in place, producing invalid JS: -// `component` / `hook` declarations, enums and `match (...) {` (not `.match(`) +// `component` / `hook` declarations, enums and `match (...)`. `match(` also +// hits plain calls (not `.match(`); those only take the slower path. const UNSUPPORTED_FLOW_SYNTAX_REGEX = - /\b(?:(?:component|hook)\s+[\w$]+\s*[<(]|enum\s+[\w$]+\s*(?:of\s+[\w$]+\s*)?\{)|(?, source: string, options: FlowLoaderOptions ) { + // only lowered files need Babel, which comes from the project + const babel: typeof import('@babel/core') = require('@babel/core'); const projectRoot = loaderContext.rootContext; const ast = await parseReactNativeSource(source, { projectRoot, flow: options.all ? 'all' : 'detect', }); - const result = transformFromAstSync(ast, source, { + const result = babel.transformFromAstSync(ast, source, { babelrc: false, configFile: false, filename: loaderContext.resourcePath, @@ -31,10 +51,17 @@ async function lowerFlowSyntax( sourceMaps: loaderContext.sourceMap, sourceFileName: loaderContext.resourcePath, plugins: [ - resolveFromReactNativePreset( - projectRoot, - '@babel/plugin-transform-flow-strip-types' - ), + [ + resolveFromReactNativePreset( + projectRoot, + '@babel/plugin-transform-flow-strip-types' + ), + // keep uninitialized fields like `flow-remove-types` does by default + { allowDeclareFields: true }, + ], + ...(options.ignoreUninitializedFields + ? [removeUninitializedClassFields] + : []), ], }); @@ -47,7 +74,7 @@ async function lowerFlowSyntax( return { code: result.code, map: result.map ?? undefined }; } -export default function flowLoader( +export default async function flowLoader( this: LoaderContext, source: string ) { @@ -55,16 +82,17 @@ export default function flowLoader( const callback = this.async(); const options = getOptions(this); - if (UNSUPPORTED_FLOW_SYNTAX_REGEX.test(source)) { - lowerFlowSyntax(this, source, options).then( - ({ code, map }) => callback(null, code, map as RawSourceMap | undefined), - (error) => callback(error) - ); - return; - } - - const result = flowRemoveTypes(source, options); - const sourceMap = options.pretty ? result.generateMap() : undefined; + try { + if (needsFlowLowering(source)) { + const { code, map } = await lowerFlowSyntax(this, source, options); + callback(null, code, map as RawSourceMap | undefined); + return; + } - callback(null, result.toString(), sourceMap as RawSourceMap); + const result = flowRemoveTypes(source, options); + const sourceMap = options.pretty ? result.generateMap() : undefined; + callback(null, result.toString(), sourceMap as RawSourceMap); + } catch (error) { + callback(error as Error); + } } diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/class-fields.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/class-fields.js.txt new file mode 100644 index 000000000..c9f474eea --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/class-fields.js.txt @@ -0,0 +1,11 @@ +// @flow + +export class Store { + declare kind: string; + count: number; +} + +// a hook sends the file to the React Native parser +export hook useStore(): Store { + return new Store(); +} diff --git a/tests/integration/src/loaders/react-native-parser.test.ts b/tests/integration/src/loaders/react-native-parser.test.ts index c81852b18..aa87911a6 100644 --- a/tests/integration/src/loaders/react-native-parser.test.ts +++ b/tests/integration/src/loaders/react-native-parser.test.ts @@ -49,15 +49,14 @@ const flowSyntaxFiles = [ 'hook.js', 'enum.js', 'match.js', + 'class-fields.js', ]; const FLOW_LIB_FILES: Record = { 'package.json': '{ "name": "flow-lib", "main": "index.js" }', 'index.js': [ '// @flow', - "import type { Marker } from './marker';", - // unused value import, kept for its side effect - "import marker from './marker';", + "export { label } from './register';", '', 'export enum Mode { Visible, Hidden }', '', @@ -73,6 +72,17 @@ const FLOW_LIB_FILES: Record = { " return match (count) { 0 => 'none', _ => 'some' };", '}', ].join('\n'), + // plain Flow (no lowering), so it pins how unused imports are stripped + 'register.js': [ + '// @flow', + "import type { Marker } from './marker';", + // unused value import, kept for its side effect + "import marker from './marker';", + '', + 'export function label(value: Marker): string {', + ' return value;', + '}', + ].join('\n'), 'marker.js': [ '// @flow', 'export type Marker = string;', @@ -437,7 +447,12 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { { devtool = false, project = '.', - }: { devtool?: 'source-map' | false; project?: '.' | 'react18' } = {} + loaderOptions = {}, + }: { + devtool?: 'source-map' | false; + project?: '.' | 'react18'; + loaderOptions?: Record; + } = {} ) { const projectRoot = path.join(workspaceRoot, preset, project); return compileAndClose({ @@ -456,7 +471,7 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { test: /\.js$/, use: { loader: require.resolve('@callstack/repack/flow-loader'), - options: { all: true }, + options: { all: true, ...loaderOptions }, }, }, ], @@ -466,12 +481,30 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { async function bundleWithFlowLoader( file: (typeof flowSyntaxFiles)[number], - project?: '.' | 'react18' + project?: '.' | 'react18', + loaderOptions?: Record ) { - const { code } = await compileWithFlowLoader(file, { project }); + const { code } = await compileWithFlowLoader(file, { + project, + loaderOptions, + }); return executeBundle(code); } + // lowered files must strip class fields the same way `flow-remove-types` does + it.each([ + [false, ['count']], + [true, []], + ])( + 'strips class fields of lowered files with ignoreUninitializedFields: %s', + async (ignoreUninitializedFields, fields) => { + const { useStore } = (await bundleWithFlowLoader('class-fields.js', '.', { + ignoreUninitializedFields, + })) as { useStore: () => object }; + expect(Object.keys(useStore())).toEqual(fields); + } + ); + it('maps lowered Flow syntax back to the original source', async () => { const { volume } = await compileWithFlowLoader('component.js', { devtool: 'source-map', diff --git a/website/src/latest/api/loaders/flow-loader.md b/website/src/latest/api/loaders/flow-loader.md index 35a3e4af7..9afc0aa90 100644 --- a/website/src/latest/api/loaders/flow-loader.md +++ b/website/src/latest/api/loaders/flow-loader.md @@ -5,7 +5,7 @@ The `FlowLoader` removes Flow type annotations from JavaScript files, ensuring t :::details Most files go through `flow-remove-types`. You can learn more about it [here](https://github.com/facebook/flow/tree/main/packages/flow-remove-types). -`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. `component` declarations are lowered for the React version installed in your project: `ref` becomes a regular prop on React 19 and `forwardRef` is used on React 18. +`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. `component` declarations are lowered for the React version installed in your project: `ref` becomes a regular prop on React 19 and `forwardRef` is used on React 18. For these files `ignoreUninitializedFields` is respected, while `pretty` and `removeEmptyImports: false` don't apply. ::: ## Options From 28c9b639045e1a245f5505e5667948f2b1de1455 Mon Sep 17 00:00:00 2001 From: Amir Saifutdinov Date: Thu, 15 Oct 2026 20:50:00 +0300 Subject: [PATCH 5/6] fix(babel-loader): handle source maps from previous loaders correctly --- .changeset/babel-loader-input-source-map.md | 5 + .../src/loaders/babelLoader/babelLoader.ts | 10 +- .../repack/src/loaders/babelLoader/utils.ts | 14 ++- .../loaders/babelSwcLoader/babelSwcLoader.ts | 35 +++--- .../src/loaders/react-native-parser.test.ts | 116 ++++++++++++++---- 5 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 .changeset/babel-loader-input-source-map.md diff --git a/.changeset/babel-loader-input-source-map.md b/.changeset/babel-loader-input-source-map.md new file mode 100644 index 000000000..eec061f7b --- /dev/null +++ b/.changeset/babel-loader-input-source-map.md @@ -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. diff --git a/packages/repack/src/loaders/babelLoader/babelLoader.ts b/packages/repack/src/loaders/babelLoader/babelLoader.ts index 13ea43a56..9aa4e4844 100644 --- a/packages/repack/src/loaders/babelLoader/babelLoader.ts +++ b/packages/repack/src/loaders/babelLoader/babelLoader.ts @@ -6,7 +6,7 @@ import { type TransformOptions, transformFromAstSync, } from '@babel/core'; -import type { LoaderContext } from '@rspack/core'; +import type { LoaderContext, RawSourceMap } from '@rspack/core'; import type { BabelLoaderOptions, BabelPluginOverrides, @@ -16,6 +16,7 @@ import { isIgnoredRepackDeepImport, isTSXSource, isTypeScriptSource, + normalizeInputSourceMap, parseReactNativeSource, shouldUseReactNativeParser, } from './utils.js'; @@ -134,7 +135,7 @@ export const transform = async ( export default async function babelLoader( this: LoaderContext, source: string, - sourceMap: string | undefined + sourceMap: string | RawSourceMap | undefined ) { this.cacheable(); const callback = this.async(); @@ -143,7 +144,6 @@ export default async function babelLoader( const { hermesParserPath, hermesParserOverrides, ...babelOverrides } = options; - const inputSourceMap = sourceMap ? JSON.parse(sourceMap) : undefined; const withSourceMaps = this.resourcePath.match(/node_modules/) ? false : this.sourceMap; @@ -156,7 +156,9 @@ export default async function babelLoader( filename: this.resourcePath, sourceMaps: withSourceMaps, sourceFileName: this.resourcePath, - inputSourceMap: withSourceMaps ? inputSourceMap : undefined, + inputSourceMap: withSourceMaps + ? normalizeInputSourceMap(sourceMap) + : undefined, ...babelOverrides, }, { diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index 8ea2c3858..5be2f09e0 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -1,4 +1,5 @@ -import type { ParseResult } from '@babel/core'; +import type { ParseResult, TransformOptions } from '@babel/core'; +import type { RawSourceMap } from '@rspack/core'; import { importDefaultESM } from '../../helpers/index.js'; import { resolveFlowEnumsRuntime, @@ -34,6 +35,17 @@ export function isTSXSource(fileName: string) { return !!fileName && fileName.endsWith('.tsx'); } +/** + * Previous loaders pass their source map either serialized or as an object + * (e.g. `flow-loader`, Babel), while Babel expects an object. + */ +export function normalizeInputSourceMap( + sourceMap: string | RawSourceMap | undefined +): TransformOptions['inputSourceMap'] { + if (!sourceMap) return undefined; + return typeof sourceMap === 'string' ? JSON.parse(sourceMap) : sourceMap; +} + /** * Decides whether a source file needs the React Native parser (hermes-parser or flow-parser). * diff --git a/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts b/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts index e570473ef..f22e6b786 100644 --- a/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts +++ b/packages/repack/src/loaders/babelSwcLoader/babelSwcLoader.ts @@ -1,6 +1,11 @@ import type { TransformOptions } from '@babel/core'; -import type { LoaderContext, SwcLoaderOptions } from '@rspack/core'; +import type { + LoaderContext, + RawSourceMap, + SwcLoaderOptions, +} from '@rspack/core'; import { transform } from '../babelLoader/babelLoader.js'; +import { normalizeInputSourceMap } from '../babelLoader/utils.js'; import type { BabelSwcLoaderOptions } from './options.js'; import { addSwcComplementaryTransforms, @@ -18,7 +23,6 @@ import { } from './utils.js'; type BabelTransform = [string, Record | undefined]; -type InputSourceMap = TransformOptions['inputSourceMap']; export const raw = false; @@ -96,7 +100,7 @@ export function buildFinalSwcConfig( export default async function babelSwcLoader( this: LoaderContext, source: string, - sourceMap: string | undefined + sourceMap: string | RawSourceMap | undefined ) { this.cacheable(); const callback = this.async(); @@ -107,9 +111,6 @@ export default async function babelSwcLoader( checkParallelModeAvailable(this, logger); } - const inputSourceMap: InputSourceMap = sourceMap - ? JSON.parse(sourceMap) - : undefined; const lazyImports = options.lazyImports ?? false; const projectRoot = getProjectRootPath(this); @@ -117,17 +118,19 @@ export default async function babelSwcLoader( ? false : this.sourceMap; - const baseBabelConfig: TransformOptions = { - caller: { name: '@callstack/repack' }, - root: projectRoot, - filename: this.resourcePath, - sourceMaps: withSourceMaps, - sourceFileName: this.resourcePath, - inputSourceMap: withSourceMaps ? inputSourceMap : undefined, - ...options.babelOverrides, - }; - try { + const baseBabelConfig: TransformOptions = { + caller: { name: '@callstack/repack' }, + root: projectRoot, + filename: this.resourcePath, + sourceMaps: withSourceMaps, + sourceFileName: this.resourcePath, + inputSourceMap: withSourceMaps + ? normalizeInputSourceMap(sourceMap) + : undefined, + ...options.babelOverrides, + }; + const swc = await lazyGetSwc(this); // if swc is not available, use babel to transform everything if (!swc) { diff --git a/tests/integration/src/loaders/react-native-parser.test.ts b/tests/integration/src/loaders/react-native-parser.test.ts index ac9867854..d038bfc85 100644 --- a/tests/integration/src/loaders/react-native-parser.test.ts +++ b/tests/integration/src/loaders/react-native-parser.test.ts @@ -227,6 +227,22 @@ async function compileAndClose(config: Parameters[0]) { } } +function getBabelLoaderUse( + loader: (typeof loaders)[number], + projectRoot: string +) { + return { + loader: require.resolve(`@callstack/repack/${loader}`), + options: + loader === 'babel-loader' + ? { root: projectRoot } + : { + hideParallelModeWarning: true, + babelOverrides: { cwd: projectRoot }, + }, + }; +} + function executeBundle( code: string, runtimeRequire?: (name: string) => unknown @@ -347,16 +363,7 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { rules: [ { test: /\.[jt]sx?$/, - use: { - loader: require.resolve(`@callstack/repack/${loader}`), - options: - loader === 'babel-loader' - ? { root: projectRoot } - : { - hideParallelModeWarning: true, - babelOverrides: { cwd: projectRoot }, - }, - }, + use: getBabelLoaderUse(loader, projectRoot), }, ], }, @@ -419,13 +426,7 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { use: { ...rule.use, loader: require.resolve('babel-loader', { paths: [repackRoot] }), - options: { - ...rule.use.options, - cwd: projectRoot, - // Stock Babel emits map objects; Re.Pack's loader expects a - // serialized input map. Keep this syntax-selection test focused. - sourceMaps: false, - }, + options: { ...rule.use.options, cwd: projectRoot }, }, })), ], @@ -532,6 +533,76 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { }); }); + // flow-loader hands its source map to the Babel loader as an object + function compileWithFlowAndBabelLoader( + loader: (typeof loaders)[number], + file: string, + flowLoaderOptions: Record = {} + ) { + const projectRoot = path.join(workspaceRoot, preset); + return compileAndClose({ + context: projectRoot, + mode: 'development', + devtool: 'source-map', + entry: `./src/${file}`, + output: { + path: '/out', + library: { type: 'commonjs2' }, + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + // loaders run from last to first + use: [ + getBabelLoaderUse(loader, projectRoot), + { + loader: require.resolve('@callstack/repack/flow-loader'), + options: { all: true, ...flowLoaderOptions }, + }, + ], + }, + ], + }, + }); + } + + it.each(loaders)( + 'maps lowered Flow syntax back to the original source through flow-loader and %s', + async (loader) => { + const { code, volume } = await compileWithFlowAndBabelLoader( + loader, + 'component.js' + ); + + expect(executeBundle(code)).toHaveProperty('Greeting'); + const sourceMap = JSON.parse( + volume.readFileSync('/out/main.js.map', 'utf-8') as string + ); + const sourcePath = path.join(workspaceRoot, preset, 'src/component.js'); + const index = sourceMap.sources.indexOf(sourcePath); + expect(index).toBeGreaterThanOrEqual(0); + expect(sourceMap.sourcesContent[index]).toBe( + fs.readFileSync(sourcePath, 'utf-8') + ); + } + ); + + // `flow-remove-types` names the source of its pretty map `source.js`, so + // only check that the Babel loader accepts the map + it.each(loaders)( + 'accepts the pretty source map of flow-loader in %s', + async (loader) => { + const { code } = await compileWithFlowAndBabelLoader(loader, 'flow.js', { + pretty: true, + }); + + expect(executeBundle(code)).toEqual({ flowResult: 'Hello Flow' }); + } + ); + // The preset strips Flow enums before its enum plugin runs, so the React // Native parser has to lower them itself async function bundleWithBabelLoader( @@ -552,16 +623,7 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { test: /\.js$/, // keep SWC's own ESM helpers out of the SWC transform exclude: /node_modules/, - use: { - loader: require.resolve(`@callstack/repack/${loader}`), - options: - loader === 'babel-loader' - ? { root: projectRoot } - : { - hideParallelModeWarning: true, - babelOverrides: { cwd: projectRoot }, - }, - }, + use: getBabelLoaderUse(loader, projectRoot), }, ], }, From aa169f8b929c1713bd47de34680cbd3bb3b8f24f Mon Sep 17 00:00:00 2001 From: Amir Saifutdinov Date: Thu, 15 Oct 2026 21:23:06 +0300 Subject: [PATCH 6/6] fix(loaders): enhance Flow enum handling in babel-loader and flow-loader --- .changeset/babel-loader-flow-enums.md | 2 +- .changeset/flow-loader-component-syntax.md | 2 +- .../src/helpers/resolveReactNativeParser.ts | 40 +++++++++++++- .../src/loaders/babelLoader/babelLoader.ts | 45 +++++++++++----- .../repack/src/loaders/babelLoader/utils.ts | 48 +---------------- .../src/loaders/flowLoader/flowLoader.ts | 7 ++- .../react-native-parser/enum-no-pragma.js.txt | 8 +++ .../src/loaders/react-native-parser.test.ts | 54 +++++++++++++------ .../src/latest/api/loaders/babel-loader.md | 4 +- .../latest/api/loaders/babel-swc-loader.md | 4 +- website/src/latest/api/loaders/flow-loader.md | 2 +- 11 files changed, 134 insertions(+), 82 deletions(-) create mode 100644 tests/integration/src/loaders/__fixtures__/react-native-parser/enum-no-pragma.js.txt diff --git a/.changeset/babel-loader-flow-enums.md b/.changeset/babel-loader-flow-enums.md index ae00b06f1..52bfd0a49 100644 --- a/.changeset/babel-loader-flow-enums.md +++ b/.changeset/babel-loader-flow-enums.md @@ -2,4 +2,4 @@ "@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. The React Native parser now lowers enums to `flow-enums-runtime`, resolved from the project or from `react-native`. Flow `component` declarations are also lowered for the project's React version instead of always targeting React 19 (`hermesParserOverrides.reactRuntimeTarget` still takes precedence). +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). diff --git a/.changeset/flow-loader-component-syntax.md b/.changeset/flow-loader-component-syntax.md index 2f80e88b2..200967904 100644 --- a/.changeset/flow-loader-component-syntax.md +++ b/.changeset/flow-loader-component-syntax.md @@ -2,4 +2,4 @@ "@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`) 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`. +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`. diff --git a/packages/repack/src/helpers/resolveReactNativeParser.ts b/packages/repack/src/helpers/resolveReactNativeParser.ts index 9db54f3fc..61051e5d8 100644 --- a/packages/repack/src/helpers/resolveReactNativeParser.ts +++ b/packages/repack/src/helpers/resolveReactNativeParser.ts @@ -1,4 +1,5 @@ import { createRequire } from 'node:module'; +import type { PluginItem, types } from '@babel/core'; function resolvePreset(projectRoot: string) { const presetPath = require.resolve( @@ -62,7 +63,7 @@ export function resolveFromReactNativePreset( * 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. */ -export const resolveFlowEnumsRuntime = cacheByProjectRoot( +const resolveFlowEnumsRuntime = cacheByProjectRoot( (projectRoot): string | null => { try { return require.resolve('flow-enums-runtime', { paths: [projectRoot] }); @@ -79,6 +80,43 @@ export const resolveFlowEnumsRuntime = cacheByProjectRoot( } ); +/** + * `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, diff --git a/packages/repack/src/loaders/babelLoader/babelLoader.ts b/packages/repack/src/loaders/babelLoader/babelLoader.ts index 9aa4e4844..3315e903a 100644 --- a/packages/repack/src/loaders/babelLoader/babelLoader.ts +++ b/packages/repack/src/loaders/babelLoader/babelLoader.ts @@ -1,12 +1,15 @@ import { type BabelFileResult, + createConfigItem, loadOptions, + loadPartialConfig, type ParseResult, parseSync, type TransformOptions, transformFromAstSync, } from '@babel/core'; import type { LoaderContext, RawSourceMap } from '@rspack/core'; +import { getFlowEnumsPlugins } from '../../helpers/resolveReactNativeParser.js'; import type { BabelLoaderOptions, BabelPluginOverrides, @@ -25,15 +28,20 @@ export const raw = false; function buildBabelConfig( babelOptions: TransformOptions, - { includePlugins, excludePlugins }: BabelPluginOverrides + { + includePlugins, + excludePlugins, + lowerFlowEnums, + }: BabelPluginOverrides & { lowerFlowEnums: boolean } ): TransformOptions { const config: TransformOptions = { babelrc: true, highlightCode: true, comments: true, - plugins: [], sourceType: 'unambiguous', ...babelOptions, + // a copy: loader options are shared between modules + plugins: [...(babelOptions.plugins ?? []), ...(includePlugins ?? [])], // output settings ast: false, code: true, @@ -43,11 +51,24 @@ function buildBabelConfig( minified: false, }; - if (includePlugins) { - config.plugins!.push(...includePlugins); + const partialConfig = loadPartialConfig(config); + if (!partialConfig) { + throw new Error('Failed to load babel config'); + } + + const { options } = partialConfig; + if (lowerFlowEnums) { + // top-level plugins run ahead of the preset, which strips enums before + // its own enum plugin runs; `root` is resolved by Babel at this point + options.plugins = [ + ...getFlowEnumsPlugins(options.root!).map((plugin) => + createConfigItem(plugin, { type: 'plugin' }) + ), + ...(options.plugins ?? []), + ]; } - const babelConfig = loadOptions(config); + const babelConfig = loadOptions(options); if (!babelConfig) { throw new Error('Failed to load babel config'); } @@ -76,25 +97,25 @@ export const transform = async ( transformOptions: TransformOptions, customOptions?: CustomTransformOptions ): Promise => { + // filename is always set by the loaders + const isTypeScript = + isTypeScriptSource(transformOptions.filename!) || + isTSXSource(transformOptions.filename!); + const babelConfig = buildBabelConfig(transformOptions, { includePlugins: customOptions?.includePlugins, excludePlugins: customOptions?.excludePlugins, + lowerFlowEnums: !isTypeScript, }); const projectRoot = babelConfig.root ?? babelConfig.cwd; - // filename will be always defined at this point - const isTypeScript = - isTypeScriptSource(babelConfig.filename!) || - isTSXSource(babelConfig.filename!); - const needsReactNativeParser = !isTypeScript && shouldUseReactNativeParser(src, customOptions?.hermesParserOverrides?.flow); let sourceAst: ParseResult | null; if (needsReactNativeParser) { - // the parser comes from the preset, it also lowers Flow enums that the - // preset would otherwise strip before its enum plugin runs + // the parser comes from the preset sourceAst = await parseReactNativeSource(src, { projectRoot: projectRoot ?? process.cwd(), parserPath: customOptions?.hermesParserPath, diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index 5be2f09e0..a11472c3e 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -2,7 +2,6 @@ import type { ParseResult, TransformOptions } from '@babel/core'; import type { RawSourceMap } from '@rspack/core'; import { importDefaultESM } from '../../helpers/index.js'; import { - resolveFlowEnumsRuntime, resolveReactNativeParser, resolveReactRuntimeTarget, } from '../../helpers/resolveReactNativeParser.js'; @@ -18,9 +17,6 @@ interface ReactNativeParser { HermesParserOverrides['reactRuntimeTarget'] >; sourceType: HermesParserOverrides['sourceType'] | null; - transformOptions?: { - TransformEnumSyntax?: { enable: boolean; getRuntime?: () => unknown }; - }; } ) => ParseResult; } @@ -86,8 +82,8 @@ export async function loadReactNativeParser( * Parses a Flow source with the project's React Native parser into a Babel AST. * * The parser lowers Flow syntax that has no Babel plugin (e.g. `component` - * declarations) and Flow enums to `flow-enums-runtime`; the remaining type - * annotations are left for `@babel/plugin-transform-flow-strip-types`. + * declarations); enums are left for `babel-plugin-transform-flow-enums` and + * type annotations for `@babel/plugin-transform-flow-strip-types`. */ export async function parseReactNativeSource( src: string, @@ -106,7 +102,6 @@ export async function parseReactNativeSource( } ): Promise { const parser = await loadReactNativeParser(projectRoot, parserPath); - const runtimePath = resolveFlowEnumsRuntime(projectRoot); return parser.parse(src, { babel: true, @@ -115,48 +110,9 @@ export async function parseReactNativeSource( reactRuntimeTarget: resolveReactRuntimeTarget(projectRoot), sourceType, ...overrides, - transformOptions: { - TransformEnumSyntax: { - enable: true, - // the parser defaults to `require('flow-enums-runtime')` - getRuntime: runtimePath ? () => requireCall(runtimePath) : undefined, - }, - }, }); } -// ESTree `require(request)` in the shape of the parser's own AST builders -function requireCall(request: string) { - const syntheticLocation = () => ({ - loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }, - range: [0, 0], - parent: null, - }); - - return { - type: 'CallExpression', - callee: { - type: 'Identifier', - name: 'require', - optional: false, - typeAnnotation: null, - ...syntheticLocation(), - }, - arguments: [ - { - type: 'Literal', - value: request, - raw: JSON.stringify(request), - literalType: 'string', - ...syntheticLocation(), - }, - ], - typeArguments: null, - optional: false, - ...syntheticLocation(), - }; -} - const IGNORED_REPACK_FILENAMES = [ 'IncludeModules.js', 'WebpackHMRClient.js', diff --git a/packages/repack/src/loaders/flowLoader/flowLoader.ts b/packages/repack/src/loaders/flowLoader/flowLoader.ts index aae1319d3..b9f4056e2 100644 --- a/packages/repack/src/loaders/flowLoader/flowLoader.ts +++ b/packages/repack/src/loaders/flowLoader/flowLoader.ts @@ -1,7 +1,10 @@ import type { PluginObj } from '@babel/core'; import type { LoaderContext, RawSourceMap } from '@rspack/core'; import flowRemoveTypes from 'flow-remove-types'; -import { resolveFromReactNativePreset } from '../../helpers/resolveReactNativeParser.js'; +import { + getFlowEnumsPlugins, + resolveFromReactNativePreset, +} from '../../helpers/resolveReactNativeParser.js'; import { parseReactNativeSource } from '../babelLoader/utils.js'; import { type FlowLoaderOptions, getOptions } from './options.js'; @@ -51,6 +54,8 @@ async function lowerFlowSyntax( sourceMaps: loaderContext.sourceMap, sourceFileName: loaderContext.resourcePath, plugins: [ + // before `flow-strip-types`, which would remove the enums + ...getFlowEnumsPlugins(projectRoot), [ resolveFromReactNativePreset( projectRoot, diff --git a/tests/integration/src/loaders/__fixtures__/react-native-parser/enum-no-pragma.js.txt b/tests/integration/src/loaders/__fixtures__/react-native-parser/enum-no-pragma.js.txt new file mode 100644 index 000000000..e402fa6b4 --- /dev/null +++ b/tests/integration/src/loaders/__fixtures__/react-native-parser/enum-no-pragma.js.txt @@ -0,0 +1,8 @@ +export enum Status { + Active, + Paused, +} + +export function parseStatus(value: string): ?Status { + return Status.cast(value); +} diff --git a/tests/integration/src/loaders/react-native-parser.test.ts b/tests/integration/src/loaders/react-native-parser.test.ts index d038bfc85..f12ee58f5 100644 --- a/tests/integration/src/loaders/react-native-parser.test.ts +++ b/tests/integration/src/loaders/react-native-parser.test.ts @@ -48,6 +48,7 @@ const flowSyntaxFiles = [ 'ref-component.js', 'hook.js', 'enum.js', + 'enum-no-pragma.js', 'match.js', 'class-fields.js', ]; @@ -229,16 +230,17 @@ async function compileAndClose(config: Parameters[0]) { function getBabelLoaderUse( loader: (typeof loaders)[number], - projectRoot: string + projectRoot: string, + babelOptions?: Record ) { return { loader: require.resolve(`@callstack/repack/${loader}`), options: loader === 'babel-loader' - ? { root: projectRoot } + ? { root: projectRoot, ...babelOptions } : { hideParallelModeWarning: true, - babelOverrides: { cwd: projectRoot }, + babelOverrides: { cwd: projectRoot, ...babelOptions }, }, }; } @@ -603,12 +605,13 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { } ); - // The preset strips Flow enums before its enum plugin runs, so the React - // Native parser has to lower them itself + // The preset strips Flow enums before its enum plugin runs, so the loaders + // run the enum plugin ahead of the preset async function bundleWithBabelLoader( loader: (typeof loaders)[number], file: (typeof flowSyntaxFiles)[number], - project: '.' | 'react18' = '.' + project: '.' | 'react18' = '.', + babelOptions?: Record ) { const projectRoot = path.join(workspaceRoot, preset, project); const { code } = await compileAndClose({ @@ -623,7 +626,7 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { test: /\.js$/, // keep SWC's own ESM helpers out of the SWC transform exclude: /node_modules/, - use: getBabelLoaderUse(loader, projectRoot), + use: getBabelLoaderUse(loader, projectRoot, babelOptions), }, ], }, @@ -632,18 +635,35 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { return executeBundle(code); } - it.each(loaders)('keeps Flow enums working with %s', async (loader) => { - const { Status, parseStatus } = (await bundleWithBabelLoader( - loader, - 'enum.js' - )) as { - Status: { Active: unknown; members: () => Iterable }; - parseStatus: (value: string) => unknown; - }; - expect(parseStatus('Active')).toBe(Status.Active); - expect([...Status.members()]).toEqual(['Active', 'Paused']); + // files without the pragma skip the React Native parser + describe.each(['enum.js', 'enum-no-pragma.js'] as const)('%s', (file) => { + it.each(loaders)('keeps Flow enums working with %s', async (loader) => { + const { Status, parseStatus } = (await bundleWithBabelLoader( + loader, + file + )) as { + Status: { Active: unknown; members: () => Iterable }; + parseStatus: (value: string) => unknown; + }; + expect(parseStatus('Active')).toBe(Status.Active); + expect([...Status.members()]).toEqual(['Active', 'Paused']); + }); }); + // Babel resolves a relative `root` against `cwd`, not the process directory + it.each(loaders)( + 'keeps Flow enums working with a relative Babel root with %s', + async (loader) => { + const { Status } = (await bundleWithBabelLoader( + loader, + 'enum-no-pragma.js', + '.', + { cwd: path.join(workspaceRoot, preset), root: '.' } + )) as { Status: { members: () => Iterable } }; + expect([...Status.members()]).toEqual(['Active', 'Paused']); + } + ); + it.each(loaders)( 'wraps components with refs in forwardRef on React 18 with %s', async (loader) => { diff --git a/website/src/latest/api/loaders/babel-loader.md b/website/src/latest/api/loaders/babel-loader.md index 6eb1d1e92..d4f6d66c0 100644 --- a/website/src/latest/api/loaders/babel-loader.md +++ b/website/src/latest/api/loaders/babel-loader.md @@ -2,6 +2,8 @@ The `BabelLoader` runs Babel transformations for JavaScript and TypeScript sources in React Native projects. For JavaScript and Flow-typed files, it selects the parser used by your installed `@react-native/babel-preset`: `flow-parser` when declared by the preset, otherwise `hermes-parser` from the preset's syntax plugin. TypeScript and TSX files use Babel's standard parser. +Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`, in every Flow file with or without an `@flow` pragma. `@react-native/babel-preset` strips enums before its own enum plugin runs, so the loader runs the preset's `babel-plugin-transform-flow-enums` ahead of the preset. + :::info How this loader differs from babel-loader There are two similarly named loaders: `@callstack/repack/babel-loader` (this loader) and `babel-loader` from npm. This loader is tailored for Re.Pack and aims for Metro parity, so the same Babel config used in Metro works as-is in Re.Pack. It automatically selects the React Native parser for JS/JSX and Flow, and Babel's parser for TypeScript and TSX. @@ -36,7 +38,7 @@ Optional path to the React Native parser module. This overrides automatic parser - Type: `HermesParserOverrides` - Default: `{ babel: true, reactRuntimeTarget }`, where `reactRuntimeTarget` follows the project's `react` version (`'18'` before React 19, `'19'` otherwise) -Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. Flow enums are lowered by the parser to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`, because `@react-native/babel-preset` strips them before its enum plugin runs. +Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. ```ts type HermesParserOverrides = { diff --git a/website/src/latest/api/loaders/babel-swc-loader.md b/website/src/latest/api/loaders/babel-swc-loader.md index e04938576..69d057166 100644 --- a/website/src/latest/api/loaders/babel-swc-loader.md +++ b/website/src/latest/api/loaders/babel-swc-loader.md @@ -7,6 +7,8 @@ SWC can be provided either by Rspack (via its built-in integration) or by instal For JavaScript and Flow-typed files, the loader selects the parser used by your installed `@react-native/babel-preset`: `flow-parser` when declared by the preset, otherwise `hermes-parser` from the preset's syntax plugin. TypeScript and TSX files use Babel's standard parser. +Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`, in every Flow file with or without an `@flow` pragma. `@react-native/babel-preset` strips enums before its own enum plugin runs, so the loader runs the preset's `babel-plugin-transform-flow-enums` ahead of the preset. + :::danger Heads up! Do not use `@callstack/repack/babel-swc-loader` together with `getJSTransformRules`. They overlap in functionality and will duplicate transforms when combined in one configuration. It might often result in code that's malformed and won't execute properly in the target mobile environment. ::: @@ -85,7 +87,7 @@ Optional path to the React Native parser module. This overrides automatic parser - Type: `HermesParserOverrides` - Default: `{ babel: true, reactRuntimeTarget }`, where `reactRuntimeTarget` follows the project's `react` version (`'18'` before React 19, `'19'` otherwise) -Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. Flow enums are lowered by the parser to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`, because `@react-native/babel-preset` strips them before its enum plugin runs. +Overrides passed to the selected React Native parser when parsing non-TypeScript files. The option name is retained for backward compatibility and also applies to `flow-parser`. ```ts type HermesParserOverrides = { diff --git a/website/src/latest/api/loaders/flow-loader.md b/website/src/latest/api/loaders/flow-loader.md index 9afc0aa90..601b7933e 100644 --- a/website/src/latest/api/loaders/flow-loader.md +++ b/website/src/latest/api/loaders/flow-loader.md @@ -5,7 +5,7 @@ The `FlowLoader` removes Flow type annotations from JavaScript files, ensuring t :::details Most files go through `flow-remove-types`. You can learn more about it [here](https://github.com/facebook/flow/tree/main/packages/flow-remove-types). -`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. `component` declarations are lowered for the React version installed in your project: `ref` becomes a regular prop on React 19 and `forwardRef` is used on React 18. For these files `ignoreUninitializedFields` is respected, while `pretty` and `removeEmptyImports: false` don't apply. +`flow-remove-types` only erases type annotations, so files using Flow `component` and `hook` declarations, enums or `match` expressions (React Native ships them since 0.81) are compiled with the React Native parser from your project's `@react-native/babel-preset` (`hermes-parser` or `flow-parser`) with `babel-plugin-transform-flow-enums` and `@babel/plugin-transform-flow-strip-types` instead. This requires `@react-native/babel-preset` and `@babel/core`, which React Native projects already have. Flow enums are lowered to [`flow-enums-runtime`](https://www.npmjs.com/package/flow-enums-runtime), resolved from your project or from `react-native`. `component` declarations are lowered for the React version installed in your project: `ref` becomes a regular prop on React 19 and `forwardRef` is used on React 18. For these files `ignoreUninitializedFields` is respected, while `pretty` and `removeEmptyImports: false` don't apply. ::: ## Options