Skip to content

Import interopRequireWildcard Babel helper from @babel/runtime instead of inlining it#57126

Open
ahmdshrif wants to merge 2 commits into
react:mainfrom
ahmdshrif:fix-babel-preset-interop-wildcard-helper
Open

Import interopRequireWildcard Babel helper from @babel/runtime instead of inlining it#57126
ahmdshrif wants to merge 2 commits into
react:mainfrom
ahmdshrif:fix-babel-preset-interop-wildcard-helper

Conversation

@ahmdshrif

@ahmdshrif ahmdshrif commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary:

Fixes #57123.

@react-native/babel-preset enables @babel/plugin-transform-runtime with helpers: true but, unless a runtime version is explicitly passed via enableBabelRuntime, it doesn't set the plugin's version option — so it defaults to 7.0.0. @babel/plugin-transform-runtime only imports a helper from @babel/runtime if that helper existed at the configured version; anything added after 7.0.0 is inlined into every module instead.

The most visible victim is interopRequireWildcard, which Babel injects for every import * as X from '...' (e.g. import * as React from 'react'). Its modern (WeakMap-cached) form was added after 7.0.0, so today it is duplicated into every file that uses a namespace import — while sibling helpers like interopRequireDefault (unchanged since 7.0.0) are correctly imported once. This needlessly increases bundle size.

When a version isn't explicitly provided, this resolves the installed @babel/runtime version (require('@babel/runtime/package.json').version, with the same MODULE_NOT_FOUND try/catch + fallback used by babel-preset-expo) and passes it to transform-runtime, so all helpers available in that runtime — interopRequireWildcard and other post-7.0.0 helpers like callSuper/wrapRegExp — are imported from @babel/runtime and deduplicated instead of inlined.

Changelog:

[GENERAL] [FIXED] - Import the interopRequireWildcard Babel helper from @babel/runtime instead of inlining it into every module

Test Plan:

yarn jest packages/react-native-babel-preset50 passed.

  • Regenerated the transform snapshot fixtures; the diff is contained to helper deduplication (inlined interopRequireWildcard / createForOfIteratorHelperLoose / callSuper / wrapRegExp / etc. replaced with @babel/runtime imports — net fewer lines).
  • The existing "produces parseable JavaScript output" checks still pass for every profile.
  • Added a focused regression test asserting that import * as React results in an imported interopRequireWildcard (not an inlined function _interopRequireWildcard).

Before (per module):

var _react = _interopRequireWildcard(require("react"));
function _interopRequireWildcard(e, t) { /* …inlined in every file… */ }

After:

var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
var _react = _interopRequireWildcard(require("react"));

…nlining it

When no explicit runtime version was given, @react-native/babel-preset
enabled @babel/plugin-transform-runtime without a `version`, so it
defaulted to 7.0.0. Helpers added to @babel/runtime after 7.0.0 — most
notably the modern `interopRequireWildcard` used for every
`import * as X` — were therefore inlined into every module instead of
being imported once from @babel/runtime, needlessly bloating the bundle.

Default the transform-runtime `version` to 7.14.0 (the floor at which
`interopRequireWildcard` is available) when a version isn't explicitly
provided. The helper is now imported from @babel/runtime and deduplicated.

Fixes react#57123
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 9, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Jun 9, 2026
@retyui

retyui commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

For a reference: babel/babel#18050

const runtimeVersion =
typeof options?.enableBabelRuntime === 'string'
? options.enableBabelRuntime
: '7.14.0';

@retyui retyui Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — done in c29d137. It now resolves the installed @babel/runtime version via require('@babel/runtime/package.json').version (with the same MODULE_NOT_FOUND try/catch + fallback as babel-preset-expo), so all helpers available in it are imported instead of only those up to a fixed floor. The snapshot fixtures now also dedupe callSuper, wrapRegExp, etc. Thanks for the reference!

Per review feedback: derive the transform-runtime version from the
installed @babel/runtime (matching babel-preset-expo) so all helpers
available in it are imported rather than only those up to a hardcoded
floor. Falls back to a conservative version when @babel/runtime can't
be resolved.
@retyui

retyui commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@cortinico could you please review this PR ?

@cortinico

Copy link
Copy Markdown
Contributor

@cortinico could you please review this PR ?

cc @robhogan @huntie

@robhogan robhogan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working this and drawing attention to the problem. Fixing this would be a pretty useful win. FWIW, at Meta we use something similar to transform-runtime and hoist all used helpers up to props of a global, so implementations appear once per bundle without incurring module load cost at call sites.

The problem with the approach in to the PR is we've no (cheap, cache safe) way of knowing what version of @babel/runtime is installed for the app being built - effectively this change assumes it's at least as new as the one installed near @react-native/babel-preset but that isn't really justified. Multiple copies of @babel/runtime is very common especially on larger/mature projects, sadly.

I think we could bump the required version up to something modern and tag it as a breaking change (users might need to update), but the dynamic detection is a footgun for the reasons above, IMO.

helpers: true,
regenerator: enableRegenerator,
...(isVersion && {version: options.enableBabelRuntime}),
// Fall back to a conservative version when `@babel/runtime` can't be

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if there's a newer version of @babel/runtime resolvable from the preset than that resolvable from the source location?

@robhogan

Copy link
Copy Markdown
Contributor

Ah, I see Babel has added moduleName to transform-runtime: https://babeljs.io/docs/babel-plugin-transform-runtime#modulename

That's potentially very handy - it'll take some wiring but Metro could pass that option through Babel context and have it specify something like metro:babel-runtime, which we could then safely special-case in the resolver to point to a known, common version of @babel/runtime close to Metro.

@retyui

retyui commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

@robhogan I agree that multiple copies of @babel/runtime could affect this logic. To address this, we can adopt the existing solution used by the Expo team. In v2, we can experiment with using a moduleName to ensure the correct version is resolved.

@robhogan

Copy link
Copy Markdown
Contributor

To address this, we can adopt the existing solution used by the Expo team.

This approach may be safe in a framework that's opinionated about (and can enforce) the dependencies of the code under transform (the app). It's not safe enough to be part of RN core, which can't make assumptions about the app's layout or dependencies.

Granted, assuming there is any version of @babel/runtime resolvable from app code is already not strictly safe, and that's a known problem. But assuming it's a recent version just because a recent version exists elsewhere makes the problem strictly worse, and actually very likely to bite at least some users.

So this is absolutely worth solving, but let's get it right rather than intentionally introducing a bug.

@ahmdshrif

ahmdshrif commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

This approach may be safe in a framework that's opinionated about (and can enforce) the dependencies of the code under transform

@robhogan That's a fair distinction — agreed the dynamic detection isn't safe for RN core. Dropping that.

But building on the moduleName idea: I think we can get the same guarantee without any Metro/resolver wiring, by pointing moduleName at a real package instead of a synthetic specifier:

  • Add a tiny RN-owned package, e.g. @react-native/babel-runtime, shipped as a dependency of react-native itself. It contains codegen'd one-line redirect files (helpers/objectSpread2.jsmodule.exports = require('@babel/runtime/helpers/objectSpread2')) and declares a pinned "@babel/runtime": "^7.27.0" as its own dependency.
  • The preset then passes two static strings: moduleName: '@react-native/babel-runtime' and version: '7.27.0'.

Why this resolves the concerns raised here:

  • No detection, no guessing. Both options are constants — cache-safe, no assumptions about the app's dependency layout.
  • The version floor is enforced by the package manager, not assumed. The redirect files live inside the redirector, so their require('@babel/runtime/...') resolves through the redirector's own dependency — which npm/yarn guarantee satisfies ^7.27.0, regardless of how many other (older) copies the app hoists.
  • It also closes the pre-existing hole you mentioned — "assuming there is any version resolvable from app code" — since the redirector is always installed as part of RN's own graph.
  • Bundler-agnostic. Plain Node resolution, so it works the same under Metro, Re.Pack, etc. — no resolver special-casing to maintain.

This is essentially what Next.js ships (moduleName: 'next/dist/compiled/@babel/runtime' pointing at their vendored copy), so the pattern has years of production mileage.

I verified the failure case locally: with an app that hoists @babel/runtime@7.0.0 (no objectSpread2), the version-only approach emits an import that throws MODULE_NOT_FOUND, while the redirector resolves its own nested modern copy correctly.

Costs: one small published package, a codegen script for the redirect files (with a CI check that every helper emittable at the pinned version exists), and one extra module hop per helper (elided by inline-requires).

Happy to rework this PR into that shape — package + codegen + preset change + a bundle test — if this direction sounds right to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

react-native-babel-preset doesn't import _interopRequireWildcard as bebel helper

4 participants