#18238·babel

path.evaluate() is confidently wrong for destructured bindings (returns the whole object)

Author: jormajCreated Sep 12, 2026Updated Sep 15, 2026

  • Would you like to work on a fix?

Input code

javascript
const O = { e: 80 };
function f() {
  const { e } = O;
  return e;
}

Calling path.evaluate() on that e:

{ confident: true, value: { e: 80 } }

Current behavior

A binding introduced by destructuring evaluates to the entire initialiser object, not to the destructured property — and it reports confident: true.

Because confident is set, any consumer that trusts it miscompiles. A minimal constant-folding pass (if (r.confident) path.replaceWith(t.valueToNode(r.value))) turns

javascript
function f() { const { e } = O; return e + 1; }   // 81

into

javascript
function f() { const { e } = O; return "[object Object]1"; }

With arithmetic it becomes NaN, which valueToNode emits as 0 / 0:

javascript
const { a, b, c } = O;
const x = (b - a) * c;   // → const x = 0 / 0;

Expected behavior

Either { confident: true, value: 80 }, or — if resolving destructuring is out of scope — { confident: false }. Anything but a confident wrong value.

Cause

packages/babel-traverse/src/path/evaluation.ts, the isReferencedIdentifier() branch:

typescript
const bindingPath = binding.path;
if (!bindingPath.isVariableDeclarator()) {
  deopt(bindingPath, state);
  return;
}
const initPath = bindingPath.get("init");
const value = evaluateCached(initPath, state);

For const { e } = O, the binding's path is a VariableDeclarator — its id is just an ObjectPattern rather than an Identifier. The guard passes, init (the whole object) is evaluated, and that becomes the value of e. Nothing checks the shape of id.

The sibling _resolve() in packages/babel-traverse/src/path/introspection.ts does check, and has an empty block where the pattern case would go:

typescript
if (this.isVariableDeclarator()) {
  if (this.get("id").isIdentifier()) {
    return this.get("init").resolve(dangerous, resolved);
  } else {}
}

Why it is rarely observed

Two things hide it:

  1. The guard immediately below deopts when the value is an object and binding.references > 1. It was presumably written for aliasing/size reasons, but it incidentally suppresses this bug whenever a destructured name is used more than once. It only surfaces when each binding is referenced exactly once.
  2. Bindings destructured from function parameters, imports, or call results deopt earlier, so only a local object literal in the same file reaches this code.

Measured:

each destructured name used once   → confident=true  value=NaN
each destructured name used twice  → confident=false

Impact

Metro's constant-folding-plugin (metro-transform-plugins) runs over every module in a production React Native bundle and trusts confident, so a confidently-wrong value reaches shipped apps. This has caused a silent rendering failure in a shipped React Native app: a coordinate computed from a destructured constant became NaN, and a renderer that ignores non-finite values drew nothing at all. Release builds only, since the pass does not run in dev, which makes it very hard to trace back to the bundler.

Possible solution

typescript
if (!bindingPath.isVariableDeclarator() || !bindingPath.get("id").isIdentifier()) {
  deopt(bindingPath, state);
  return;
}

Environment

  • @babel/core 7.29.7, @babel/traverse 7.29.7
  • Node v26.8.2, macOS
  • The same code is present on main at the time of filing.

Reproduction

Needs only @babel/core:

javascript
const babel = require("@babel/core");

babel.transformSync(
  `const O = { e: 80 };
   function f() { const { e } = O; return e; }`,
  { babelrc: false, configFile: false,
    plugins: [() => ({ visitor: { ReturnStatement(path) {
      const r = path.get("argument").evaluate();
      console.log("`e` evaluates to:", JSON.stringify(r.value), "| confident:", r.confident);
    }}})] },
);
// → `e` evaluates to: {"e":80} | confident: true