Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/babel-loader-flow-pragma-gate.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { transform } from '../babelLoader.js';
import { loadHermesParser } from '../utils.js';

jest.mock('../utils.js', () => {
const actual = jest.requireActual('../utils.js');
Expand All @@ -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 = () => <View test={1} />;';
Expand Down
40 changes: 26 additions & 14 deletions packages/repack/src/loaders/babelLoader/babelLoader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type BabelFileResult,
loadOptions,
type ParseResult,
parseSync,
type TransformOptions,
transformFromAstSync,
Expand All @@ -16,6 +17,7 @@ import {
isTSXSource,
isTypeScriptSource,
loadHermesParser,
shouldUseHermesParser,
} from './utils.js';

export const raw = false;
Expand Down Expand Up @@ -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}`);
Expand Down
20 changes: 20 additions & 0 deletions packages/repack/src/loaders/babelLoader/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface HermesParser {
) => ParseResult;
}

const FLOW_PRAGMA_REGEX = /@flow/;

export function isTypeScriptSource(fileName: string) {
return !!fileName && fileName.endsWith('.ts');
}
Expand All @@ -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',
Expand Down