path.evaluate() is confidently wrong for destructured bindings (returns the whole object)
- Would you like to work on a fix?
Input code
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
function f() { const { e } = O; return e + 1; } // 81into
function f() { const { e } = O; return "[object Object]1"; }With arithmetic it becomes NaN, which valueToNode emits as 0 / 0:
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:
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:
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:
- 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. - 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=falseImpact
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
if (!bindingPath.isVariableDeclarator() || !bindingPath.get("id").isIdentifier()) {
deopt(bindingPath, state);
return;
}Environment
@babel/core7.29.7,@babel/traverse7.29.7- Node v26.8.2, macOS
- The same code is present on
mainat the time of filing.
Reproduction
Needs only @babel/core:
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: trueSource: babel/babel