Description
metro-transform-plugins' constant-folding-plugin replaces arithmetic on destructured bindings with a literal 0 / 0 (NaN) in production bundles.
const SHAPE = { lo: 80, hi: 85, scale: 2.8 };
export function f() {
const { lo, hi, scale } = SHAPE;
const span = (hi - lo) * scale; // 14
...
}
After the pass:
With + instead, a value becomes a string:
const { e } = O; return e + 1; // → return "[object Object]1";
Dev builds are unaffected (the optimisation pass does not run), so this only appears in release, on both platforms, silently.
Root cause
Not Metro's own logic. The plugin asks Babel:
const evaluated = path.evaluate();
return { confident: evaluated.confident, value: evaluated.value };
and Babel returns { confident: true, value: NaN }, because path.evaluate() resolves a destructured binding to its declarator's whole init object. Filed upstream: babel/babel#18238
The plugin then does path.replaceWith(t.valueToNode(result.value)), and valueToNode(NaN) emits 0 / 0.
Why it is easy to miss
Babel only hands back the bad value when each destructured binding is referenced exactly once (a binding.references > 1 guard masks it otherwise), and only when the object is a local literal — destructuring from params, props or imports deopts earlier. So the trigger is narrow, but when it fires the result is a silent wrong value in a shipped bundle. This has caused a rendering failure in a released React Native app: a coordinate became NaN and the renderer drew nothing, with no error. Nothing fails loudly, and dev builds look fine, so it is very hard to trace back to the bundler.
Suggested defensive fix
The plugin already refuses to fold subtrees containing calls or assignments, so it is defensive by design — it just has no protection against the evaluator being confidently wrong. Declining to substitute a non-finite result would have prevented this entirely, and legitimate folds to NaN/Infinity are vanishingly rare and harmless to skip:
const Expression = {
exit(path) {
const result = evaluate(path);
if (result.confident && Number.isFinite(result.value)) { // or: typeof !== "number" || Number.isFinite
path.replaceWith(t.valueToNode(result.value));
path.skip();
}
},
};
Environment
metro 0.84.4, metro-transform-plugins 0.84.4
@babel/core / @babel/traverse 7.29.7
- React Native 0.86 / Expo SDK 57, Node v26.8.2, macOS
Reproduction
const babel = require("@babel/core");
const fold = require("metro-transform-plugins/src/constant-folding-plugin.js");
console.log(babel.transformSync(
`const O = { e: 80 };
function f() { const { e } = O; return e + 1; }`,
{ babelrc: false, configFile: false, plugins: [fold] },
).code);
// → function f() { const { e } = O; return "[object Object]1"; }
Description
metro-transform-plugins'constant-folding-pluginreplaces arithmetic on destructured bindings with a literal0 / 0(NaN) in production bundles.After the pass:
With
+instead, a value becomes a string:Dev builds are unaffected (the optimisation pass does not run), so this only appears in release, on both platforms, silently.
Root cause
Not Metro's own logic. The plugin asks Babel:
and Babel returns
{ confident: true, value: NaN }, becausepath.evaluate()resolves a destructured binding to its declarator's wholeinitobject. Filed upstream: babel/babel#18238The plugin then does
path.replaceWith(t.valueToNode(result.value)), andvalueToNode(NaN)emits0 / 0.Why it is easy to miss
Babel only hands back the bad value when each destructured binding is referenced exactly once (a
binding.references > 1guard masks it otherwise), and only when the object is a local literal — destructuring from params, props or imports deopts earlier. So the trigger is narrow, but when it fires the result is a silent wrong value in a shipped bundle. This has caused a rendering failure in a released React Native app: a coordinate becameNaNand the renderer drew nothing, with no error. Nothing fails loudly, and dev builds look fine, so it is very hard to trace back to the bundler.Suggested defensive fix
The plugin already refuses to fold subtrees containing calls or assignments, so it is defensive by design — it just has no protection against the evaluator being confidently wrong. Declining to substitute a non-finite result would have prevented this entirely, and legitimate folds to
NaN/Infinityare vanishingly rare and harmless to skip:Environment
metro0.84.4,metro-transform-plugins0.84.4@babel/core/@babel/traverse7.29.7Reproduction