diff --git a/.changeset/babel-loader-flow-enums.md b/.changeset/babel-loader-flow-enums.md new file mode 100644 index 000000000..52bfd0a49 --- /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. 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/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/.changeset/flow-loader-component-syntax.md b/.changeset/flow-loader-component-syntax.md new file mode 100644 index 000000000..200967904 --- /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`), `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/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..61051e5d8 100644 --- a/packages/repack/src/helpers/resolveReactNativeParser.ts +++ b/packages/repack/src/helpers/resolveReactNativeParser.ts @@ -1,20 +1,147 @@ import { createRequire } from 'node:module'; +import type { PluginItem, types } from '@babel/core'; -export function resolveReactNativeParser(projectRoot: string) { +function resolvePreset(projectRoot: string) { + const presetPath = require.resolve( + '@react-native/babel-preset/package.json', + { + paths: [projectRoot], + } + ); + const presetRequire = createRequire(presetPath); + const { version, dependencies } = presetRequire('./package.json'); + + return { + presetRequire, + dependencies: dependencies as Record | 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. + */ +export function resolveFromReactNativePreset( + projectRoot: string, + request: string +): string { let presetLocation = projectRoot; try { - const presetPath = require.resolve( - '@react-native/babel-preset/package.json', - { - paths: [projectRoot], - } + const { presetRequire, location } = resolvePreset(projectRoot); + presetLocation = location; + return presetRequire.resolve(request); + } catch (cause) { + throw Object.assign( + new Error( + `Failed to resolve '${request}' from ${presetLocation}. ` + + `Make sure '@react-native/babel-preset' is installed.` + ), + { cause } ); + } +} - const presetRequire = createRequire(presetPath); - const { version, dependencies } = presetRequire('./package.json'); +/** + * Resolves `flow-enums-runtime`, which lowered Flow enums require. + * + * Only React Native depends on it, so with isolated installs (pnpm) it often + * can't be resolved from the project root or from the file using an enum. + */ +const resolveFlowEnumsRuntime = cacheByProjectRoot( + (projectRoot): string | null => { + try { + return require.resolve('flow-enums-runtime', { paths: [projectRoot] }); + } catch {} - presetLocation = `@react-native/babel-preset@${version} (${presetPath})`; + try { + const reactNativePath = require.resolve('react-native/package.json', { + paths: [projectRoot], + }); + return createRequire(reactNativePath).resolve('flow-enums-runtime'); + } catch { + return null; + } + } +); + +/** + * `babel-plugin-transform-flow-enums` from the project's React Native preset, + * or nothing when the preset isn't installed. + * + * Has to run ahead of the preset: the preset's `flow-strip-types` removes enum + * declarations before the preset's own enum plugin gets to them. + */ +export const getFlowEnumsPlugins = cacheByProjectRoot( + (projectRoot): PluginItem[] => { + let pluginPath: string; + try { + pluginPath = resolveFromReactNativePreset( + projectRoot, + 'babel-plugin-transform-flow-enums' + ); + } catch { + return []; + } + + const runtimePath = resolveFlowEnumsRuntime(projectRoot); + // the plugin defaults to `require('flow-enums-runtime')` + if (!runtimePath) return [pluginPath]; + + return [ + [ + pluginPath, + { + getRuntime: (t: typeof types) => + t.callExpression(t.identifier('require'), [ + t.stringLiteral(runtimePath), + ]), + }, + ], + ]; + } +); + +/** + * Picks the React runtime that Flow `component` declarations are lowered for, + * based on the project's `react` version: React 19 passes `ref` as a prop, + * React 18 needs `forwardRef`. Defaults to React 19. + */ +export const resolveReactRuntimeTarget = cacheByProjectRoot( + (projectRoot): '18' | '19' => { + try { + const { version } = require( + require.resolve('react/package.json', { paths: [projectRoot] }) + ); + return Number.parseInt(version, 10) < 19 ? '18' : '19'; + } catch { + return '19'; + } + } +); + +export function resolveReactNativeParser(projectRoot: string) { + let presetLocation = projectRoot; + + try { + const { presetRequire, dependencies, location } = + resolvePreset(projectRoot); + presetLocation = location; // A hoisted parser must not override the one declared by the preset. if (dependencies?.['flow-parser']) { 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..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 } from '@rspack/core'; +import type { LoaderContext, RawSourceMap } from '@rspack/core'; +import { getFlowEnumsPlugins } from '../../helpers/resolveReactNativeParser.js'; import type { BabelLoaderOptions, BabelPluginOverrides, @@ -16,7 +19,8 @@ import { isIgnoredRepackDeepImport, isTSXSource, isTypeScriptSource, - loadReactNativeParser, + normalizeInputSourceMap, + parseReactNativeSource, shouldUseReactNativeParser, } from './utils.js'; @@ -24,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, @@ -42,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'); } @@ -75,34 +97,30 @@ 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) { - // 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 + 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); @@ -138,7 +156,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(); @@ -147,7 +165,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; @@ -160,7 +177,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 2f93b7431..a11472c3e 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -1,15 +1,22 @@ -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 { resolveReactNativeParser } from '../../helpers/resolveReactNativeParser.js'; +import { + resolveReactNativeParser, + resolveReactRuntimeTarget, +} from '../../helpers/resolveReactNativeParser.js'; +import type { HermesParserOverrides } from './options.js'; interface ReactNativeParser { parse: ( 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; } ) => ParseResult; } @@ -24,6 +31,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). * @@ -60,6 +78,41 @@ 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); 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, + { + projectRoot, + parserPath, + flow, + sourceType = 'unambiguous', + overrides, + }: { + projectRoot: string; + parserPath?: string; + flow?: HermesParserOverrides['flow']; + sourceType?: HermesParserOverrides['sourceType'] | null; + overrides?: HermesParserOverrides; + } +): Promise { + const parser = await loadReactNativeParser(projectRoot, parserPath); + + return parser.parse(src, { + babel: true, + // the parser rejects `flow: undefined` + ...(flow && { flow }), + reactRuntimeTarget: resolveReactRuntimeTarget(projectRoot), + sourceType, + ...overrides, + }); +} + const IGNORED_REPACK_FILENAMES = [ 'IncludeModules.js', 'WebpackHMRClient.js', 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/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 12c00250c..b9f4056e2 100644 --- a/packages/repack/src/loaders/flowLoader/flowLoader.ts +++ b/packages/repack/src/loaders/flowLoader/flowLoader.ts @@ -1,16 +1,103 @@ +import type { PluginObj } from '@babel/core'; import type { LoaderContext, RawSourceMap } from '@rspack/core'; import flowRemoveTypes from 'flow-remove-types'; -import { getOptions } from './options.js'; +import { + getFlowEnumsPlugins, + 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 (...)`. `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 = babel.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: [ + // before `flow-strip-types`, which would remove the enums + ...getFlowEnumsPlugins(projectRoot), + [ + resolveFromReactNativePreset( + projectRoot, + '@babel/plugin-transform-flow-strip-types' + ), + // keep uninitialized fields like `flow-remove-types` does by default + { allowDeclareFields: true }, + ], + ...(options.ignoreUninitializedFields + ? [removeUninitializedClassFields] + : []), + ], + }); + + if (!result?.code) { + throw new Error( + `Failed to strip Flow types: ${loaderContext.resourcePath}` + ); + } + + return { code: result.code, map: result.map ?? undefined }; +} + +export default async function flowLoader( + this: LoaderContext, + source: string +) { this.cacheable(); const callback = this.async(); const options = getOptions(this); - 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/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 d4bca6b9e..fe04ea537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1043,6 +1043,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 c39be8c76..50c150a99 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -23,6 +23,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/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/__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-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/__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/__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 24fca7809..f12ee58f5 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,55 @@ const sourceFiles = [ ]; const codegenFile = 'ParserNativeComponent.js'; +// Flow syntax that `flow-remove-types` alone cannot strip +const flowSyntaxFiles = [ + 'component.js', + 'ref-component.js', + 'hook.js', + 'enum.js', + 'enum-no-pragma.js', + 'match.js', + 'class-fields.js', +]; + +const FLOW_LIB_FILES: Record = { + 'package.json': '{ "name": "flow-lib", "main": "index.js" }', + 'index.js': [ + '// @flow', + "export { label } from './register';", + '', + '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'), + // 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;', + "globalThis.flowLibMarker = 'loaded';", + 'export default null;', + ].join('\n'), +}; let workspaceRoot: string; const originalNodePath = process.env.NODE_PATH; @@ -42,13 +103,26 @@ 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] ) { 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 +161,50 @@ 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'); + 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 + 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); linkPackage( projectRoot, '@react-native/babel-plugin-codegen', @@ -96,6 +214,7 @@ function createFixtureProject( // resolves it from its own dependencies, as with isolated (e.g. pnpm) installs. // 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]) { @@ -109,6 +228,23 @@ async function compileAndClose(config: Parameters[0]) { } } +function getBabelLoaderUse( + loader: (typeof loaders)[number], + projectRoot: string, + babelOptions?: Record +) { + return { + loader: require.resolve(`@callstack/repack/${loader}`), + options: + loader === 'babel-loader' + ? { root: projectRoot, ...babelOptions } + : { + hideParallelModeWarning: true, + babelOverrides: { cwd: projectRoot, ...babelOptions }, + }, + }; +} + function executeBundle( code: string, runtimeRequire?: (name: string) => unknown @@ -229,16 +365,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), }, ], }, @@ -301,13 +428,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 }, }, })), ], @@ -324,6 +445,339 @@ describe.each(presets)('React Native %s parser dependencies', (preset) => { }, }); }); + + function compileWithFlowLoader( + file: (typeof flowSyntaxFiles)[number], + { + devtool = false, + project = '.', + loaderOptions = {}, + }: { + devtool?: 'source-map' | false; + project?: '.' | 'react18'; + loaderOptions?: Record; + } = {} + ) { + const projectRoot = path.join(workspaceRoot, preset, project); + 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, ...loaderOptions }, + }, + }, + ], + }, + }); + } + + async function bundleWithFlowLoader( + file: (typeof flowSyntaxFiles)[number], + project?: '.' | 'react18', + loaderOptions?: Record + ) { + 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', + }); + + 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!', + }); + }); + + // 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 loaders + // run the enum plugin ahead of the preset + async function bundleWithBabelLoader( + loader: (typeof loaders)[number], + file: (typeof flowSyntaxFiles)[number], + project: '.' | 'react18' = '.', + babelOptions?: Record + ) { + 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: getBabelLoaderUse(loader, projectRoot, babelOptions), + }, + ], + }, + }); + + return executeBundle(code); + } + + // 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) => { + 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; + }; + 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; + }; + 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/babel-loader.md b/website/src/latest/api/loaders/babel-loader.md index c45eb1246..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. @@ -34,7 +36,7 @@ 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`. diff --git a/website/src/latest/api/loaders/babel-swc-loader.md b/website/src/latest/api/loaders/babel-swc-loader.md index e42f66b0e..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. ::: @@ -83,7 +85,7 @@ 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`. diff --git a/website/src/latest/api/loaders/flow-loader.md b/website/src/latest/api/loaders/flow-loader.md index f6801eee4..601b7933e 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`) 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 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 = {