Skip to content
Merged
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 { loadReactNativeParser } 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(loadReactNativeParser).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(loadReactNativeParser).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(loadReactNativeParser).toHaveBeenCalled();
});

it('skips hermes-parser for TypeScript sources', async () => {
await transform(
'// @flow\nexport const answer: number = 42;',
baseTransformOptions('/virtual/typescript.ts')
);

expect(loadReactNativeParser).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
49 changes: 36 additions & 13 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,
loadReactNativeParser,
shouldUseReactNativeParser,
} from './utils.js';

export const raw = false;
Expand Down Expand Up @@ -78,22 +80,43 @@ export const transform = async (
excludePlugins: customOptions?.excludePlugins,
});
const projectRoot = babelConfig.root ?? babelConfig.cwd;
const parser = await loadReactNativeParser(
projectRoot,
customOptions?.hermesParserPath
);

// filename will be always defined at this point
const sourceAst =
const isTypeScript =
isTypeScriptSource(babelConfig.filename!) ||
isTSXSource(babelConfig.filename!)
? parseSync(src, babelConfig)
: parser.parse(src, {
babel: true,
reactRuntimeTarget: '19',
sourceType: babelConfig.sourceType,
...customOptions?.hermesParserOverrides,
});
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',
sourceType: babelConfig.sourceType,
...customOptions?.hermesParserOverrides,
});
} else if (isTypeScript) {
sourceAst = parseSync(src, babelConfig);
} else {
// the RN parser would accept JSX & Flow regardless of babel plugins,
// keep that working when JSX/Flow transforms are excluded (e.g. handled by SWC)
sourceAst = parseSync(src, {
...babelConfig,
parserOpts: {
...babelConfig.parserOpts,
plugins: [...(babelConfig.parserOpts?.plugins ?? []), 'jsx', 'flow'],
},
});
}

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 @@ -14,6 +14,8 @@ interface ReactNativeParser {
) => ParseResult;
}

const FLOW_PRAGMA_REGEX = /@flow/;

export function isTypeScriptSource(fileName: string) {
return !!fileName && fileName.endsWith('.ts');
}
Expand All @@ -22,6 +24,24 @@ export function isTSXSource(fileName: string) {
return !!fileName && fileName.endsWith('.tsx');
}

/**
* Decides whether a source file needs the React Native parser (hermes-parser or flow-parser).
*
* Mirrors the React Native preset's parser syntax plugin with its default
* `parseLangTypes: 'flow'`, which sends only files carrying an `@flow` pragma to the parser
* and leaves everything else to `@babel/parser`. The 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 the React Native parser.
*/
export function shouldUseReactNativeParser(
src: string,
flow?: 'all' | 'detect'
): boolean {
return flow === 'all' || FLOW_PRAGMA_REGEX.test(src);
}

export async function loadReactNativeParser(
projectRoot?: string | null,
providedParserPath?: string
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/src/loaders/react-native-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ beforeAll(() => {
fs.mkdirSync(presetRoot, { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'index.js'),
'module.exports = 42;'
'// @flow\nmodule.exports = 42;'
);
fs.writeFileSync(
path.join(presetRoot, 'package.json'),
Expand Down
Loading