Skip to content

constant-folding-plugin emits 0/0 (NaN) for arithmetic on destructured bindings #1927

Description

@jormaj

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:

const span = 0 / 0;

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"; }

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions