Fold navigator member reads under optional chains (#1954)

This commit is contained in:
Chris Tate
2026-10-22 23:59:26 -05:00
committed by GitHub
5 changed files with 29 additions and 4 deletions
@@ -1,3 +1,4 @@
import { isNavigatorGlobal } from "../builtins/navigator.js";
import { isSafeToDiscard } from "./evaluation-safety.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
@@ -223,6 +224,16 @@ export function lowerOptionalChain(
// Native handle methods have no first-class value to guard; when the
// method read itself has no lowering, re-dispatch the plain call (which
// reports its own diagnostics) instead of failing on the value read.
// `navigator?.userAgent`: Node's navigator global is never nullish and
// has no value form, so the guarded step is the plain member read.
if (isNavigatorGlobal(lowerer, recvNode)) {
lowerer.chainHandled.add(dotNode);
try {
return lowerer.lowerExpr(expr);
} finally {
lowerer.chainHandled.delete(dotNode);
}
}
let loweredReceiver: IrExpr | null;
if (dotNode === expr && alwaysPresentStdlibMethod(lowerer, recvNode)) {
loweredReceiver = tryLowerExpression(lowerer, recvNode);
@@ -306,10 +306,10 @@ export function lowerPropertyAccessExpr(
};
}
}
if (!expr.questionDotToken) {
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
}
// Node's navigator is never nullish, so `navigator?.userAgent` (zod's
// Cloudflare probe) folds like `navigator.userAgent`.
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
// A dgram socket's method read as a value in JavaScript (`emit:
// socket.emit.bind(socket)`): the socket crosses as its checked-dynamic
// handle, whose methods are function values that dispatch on their
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7600-navigator-optional-chain.js"
],
"diags": []
}
@@ -0,0 +1,7 @@
// Node's navigator global read through optional chains (zod 4's
// `navigator?.userAgent?.includes("Cloudflare")` probe): navigator is
// never nullish, so each chain answers like the plain member read.
const cloudflare = typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare");
console.log(cloudflare);
console.log(navigator?.userAgent === navigator.userAgent, navigator?.userAgent?.startsWith("Node.js/"));
console.log(navigator?.userAgent?.split("/")[0] ?? "none");
@@ -0,0 +1 @@
{ "plain": 3000, "san": 22000 }