diff --git a/.changeset/babel-loader-flow-pragma-gate.md b/.changeset/babel-loader-flow-pragma-gate.md new file mode 100644 index 000000000..74a86fc9a --- /dev/null +++ b/.changeset/babel-loader-flow-pragma-gate.md @@ -0,0 +1,10 @@ +--- +"@callstack/repack": patch +--- + +Send only sources carrying an `@flow` pragma through hermes-parser in `babelLoader`, matching +`babel-plugin-syntax-hermes-parser` with the React Native preset's default +`parseLangTypes: 'flow'`. hermes-parser converts its own AST into a Babel AST, and that conversion +is quadratic in the number of sibling nodes, so a single prebuilt minified dependency could add +minutes to a build. Set `hermesParserOverrides.flow` to `'all'` to keep parsing every file with +hermes-parser. diff --git a/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts b/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts index 5bc77a195..277d8f881 100644 --- a/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts +++ b/packages/repack/src/loaders/babelLoader/__tests__/babelLoader.test.ts @@ -1,4 +1,5 @@ import { transform } from '../babelLoader.js'; +import { loadHermesParser } from '../utils.js'; jest.mock('../utils.js', () => { const actual = jest.requireActual('../utils.js'); @@ -12,12 +13,68 @@ jest.mock('../utils.js', () => { ) => parseSync(src, { sourceType: opts?.sourceType ?? 'unambiguous', + // the stand-in parser runs outside of a file context, so skip config lookup + filename: '/virtual/hermes-parser-stand-in.js', + babelrc: false, + configFile: false, }), })), }; }); +const baseTransformOptions = (filename: string) => ({ + caller: { name: 'jest-babel-loader-test' }, + filename, + sourceMaps: false, + sourceFileName: filename, + sourceRoot: '/virtual', + envName: 'production', +}); + describe('babelLoader', () => { + describe('parser selection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('skips hermes-parser for sources without an @flow pragma', async () => { + await transform( + 'export const answer = 42;', + baseTransformOptions('/virtual/plain.js') + ); + + expect(loadHermesParser).not.toHaveBeenCalled(); + }); + + it('uses hermes-parser for sources with an @flow pragma', async () => { + await transform( + '// @flow\nexport const answer = 42;', + baseTransformOptions('/virtual/flow.js') + ); + + expect(loadHermesParser).toHaveBeenCalled(); + }); + + it('uses hermes-parser for every source when flow is set to all', async () => { + await transform( + 'export const answer = 42;', + baseTransformOptions('/virtual/plain.js'), + { hermesParserOverrides: { flow: 'all' } } + ); + + expect(loadHermesParser).toHaveBeenCalled(); + }); + + it('skips hermes-parser for TypeScript sources', async () => { + await transform( + '// @flow\nexport const answer: number = 42;', + baseTransformOptions('/virtual/typescript.ts') + ); + + expect(loadHermesParser).not.toHaveBeenCalled(); + }); + }); + describe('includePlugins', () => { it('includes @babel/plugin-transform-react-jsx and transforms JSX', async () => { const src = 'export const Component = () => ;'; diff --git a/packages/repack/src/loaders/babelLoader/babelLoader.ts b/packages/repack/src/loaders/babelLoader/babelLoader.ts index e9ba968f6..dc220a68b 100644 --- a/packages/repack/src/loaders/babelLoader/babelLoader.ts +++ b/packages/repack/src/loaders/babelLoader/babelLoader.ts @@ -1,6 +1,7 @@ import { type BabelFileResult, loadOptions, + type ParseResult, parseSync, type TransformOptions, transformFromAstSync, @@ -16,6 +17,7 @@ import { isTSXSource, isTypeScriptSource, loadHermesParser, + shouldUseHermesParser, } from './utils.js'; export const raw = false; @@ -78,23 +80,33 @@ export const transform = async ( excludePlugins: customOptions?.excludePlugins, }); const projectRoot = babelConfig.root ?? babelConfig.cwd; - // load hermes parser dynamically to match the version from preset - const hermesParser = await loadHermesParser( - projectRoot, - customOptions?.hermesParserPath - ); // filename will be always defined at this point - const sourceAst = + const isTypeScript = isTypeScriptSource(babelConfig.filename!) || - isTSXSource(babelConfig.filename!) - ? parseSync(src, babelConfig) - : hermesParser.parse(src, { - babel: true, - reactRuntimeTarget: '19', - sourceType: babelConfig.sourceType, - ...customOptions?.hermesParserOverrides, - }); + isTSXSource(babelConfig.filename!); + + const needsHermesParser = + !isTypeScript && + shouldUseHermesParser(src, customOptions?.hermesParserOverrides?.flow); + + let sourceAst: ParseResult | null; + if (needsHermesParser) { + // load hermes parser dynamically to match the version from preset + const hermesParser = await loadHermesParser( + projectRoot, + customOptions?.hermesParserPath + ); + + sourceAst = hermesParser.parse(src, { + babel: true, + reactRuntimeTarget: '19', + sourceType: babelConfig.sourceType, + ...customOptions?.hermesParserOverrides, + }); + } else { + sourceAst = parseSync(src, babelConfig); + } if (!sourceAst) { throw new Error(`Failed to parse source file: ${babelConfig.filename}`); diff --git a/packages/repack/src/loaders/babelLoader/utils.ts b/packages/repack/src/loaders/babelLoader/utils.ts index 670897a32..58ae1133b 100644 --- a/packages/repack/src/loaders/babelLoader/utils.ts +++ b/packages/repack/src/loaders/babelLoader/utils.ts @@ -13,6 +13,8 @@ interface HermesParser { ) => ParseResult; } +const FLOW_PRAGMA_REGEX = /@flow/; + export function isTypeScriptSource(fileName: string) { return !!fileName && fileName.endsWith('.ts'); } @@ -21,6 +23,24 @@ export function isTSXSource(fileName: string) { return !!fileName && fileName.endsWith('.tsx'); } +/** + * Decides whether a source file needs hermes-parser. + * + * Mirrors `babel-plugin-syntax-hermes-parser` with the React Native preset's default + * `parseLangTypes: 'flow'`, which sends only files carrying an `@flow` pragma to hermes-parser + * and leaves everything else to `@babel/parser`. hermes-parser converts its own AST into a Babel + * AST, and that conversion is quadratic in the number of sibling nodes, so prebuilt minified + * dependencies can take minutes. + * + * `flow: 'all'` opts every file back into hermes-parser. + */ +export function shouldUseHermesParser( + src: string, + flow?: 'all' | 'detect' +): boolean { + return flow === 'all' || FLOW_PRAGMA_REGEX.test(src); +} + function resolveHermesParser(projectRoot: string) { const reactNativeBabelPresetPath = require.resolve( '@react-native/babel-preset',