fix(js,ts): `require('./x').default` reaches an ES module's default export
Summary
extractJSImports maps const X = require('./x').default (and const { default: X } = require('./x')) as { exportedName: 'default', isDefault: false }, so findExportedSymbol looks only for a named export called default. That is right for a CommonJS module that sets the property itself. A module written as an ES module (export default class Foo) has no named default, though, so every reference through the binding stayed unresolved or fell to name matching.
bitwarden's desktop app is the reported case. main-biometrics.service.ts:51-52 and os-biometrics-mac.service.spec.ts:105 load export default class OsBiometricsServiceMac with require("./os-biometrics-mac.service").default and new it, and both instantiates refs stayed failed.
#2412 fixed the ESM sibling (import { default as X }) by mapping it as the default import. The require form can't be mapped that way, because .default there is a property of module.exports:
- an ESM-compiled module sets it to its default export;
- a CommonJS module can set it by name (
exports.default = fn,module.exports.default = X); - the dual export
module.exports = X; module.exports.default = Xsets it to X.
Change
The named property is still looked up first. When findExportedSymbolWalk finds no named default, it falls back to the module's ESM default export, under three gates:
- JS family only (
ESM_IMPORT_LANGUAGES). Python'sfrom .mod import defaultis untouched. - Only the module the require names (
depth === 0).export * fromnever forwards a default, so the walk through a wildcard re-export doesn't take one. - Only when that module has an ESM default export at all: it is a single-file component, or has an
export defaultstatement. A CommonJS module's anonymousexports.x = function () {}is flagged exported, so without this gate the first-exported-function guess would invent a default for a module that has none. That is the expressnormalizeTypebug class from #2189.
The ESM default lookup itself (component, then the export default NAME binding, then the first exported function or class) is factored into esmDefaultExport and shared with the isDefault branch, so require('./x').default and import X from './x' land on the same declaration.
layoutComponent (frameworks/react.ts) tries context.resolveImport first. A require(…).default layout now resolves there, to the declaration its module read (lazyRouteComponent) found before. Its isDefault || exportedName === 'default' branch stays as the fallback for when import resolution finds nothing. After #2412, that branch's second half is reached only by the require forms.
Other readers of isDefault are unchanged. jsx-render's zero-candidate fallback, expo-modules, tier-synthesizer and react-router-synthesizer read the mapping flag, not findExportedSymbol. topcoder's two require('../shared').default sites feed only JSX tags (<Application />), which go through jsx-render by name, so that repo is byte-identical.
Validation
Fresh codegraph init -y indexes, scripts/dump-graph.mjs before/after, native kernel staged in both arms. The arms are main 19f91e27 and this branch's fix commit, and only dist/resolution/import-resolver.js differs between them. The main commits merged in since (#2413, #2416, #2417, #2419, #2425, #2426) don't touch import-resolver.ts, and their resolver hunks are gated to Go, C++ and sync-time navigation.
| repo | edges | notes |
|---|---|---|
| bitwarden/clients | +2 / −0 | the two failed refs now instantiates OsBiometricsServiceMac via import |
| facebook/react-native | +872 / −617 | details below |
| express, eslint, mocha, koa, body-parser (#2189's CommonJS set) | byte-identical | |
fastify (dual export, { default: fastify } = require('../../fastify')) | byte-identical | the named default already found fastify |
| topcoder community-app | byte-identical | require-default sites feed JSX tags only |
| proshop_mern, next-saas-starter, create-t3-turbo (controls) | byte-identical |
React Native's Libraries have 238 require(…).default loads:
- All 617 removed edges are re-resolved at the same call site (0 lost):
- 541 moved from the local
const X = require(…).defaultbinding to the real function or class; - 47 moved off a namesake method in another package (
JSTimers.setInterval→ an rn-tester test's method,Animated.delay→IntersectionObserver.delay,Keyboard.dismiss→ a dev-server handler) onto the right module. That is the method itself forKeyboard.dismiss. ForJSTimersandAnimatedit is the exported value, whose members are chosen at run time; - 28 keep the same target and gain
importmetadata; - 1 moved from the
log(…)signature in a Flow type to thelogimplementation.
- 541 moved from the local
- Added edges, checked against each target file's
export defaultstatement:- 834 land on the declared default or a member inside it;
- 36 land on module functions an object-literal facade lists (
ExceptionsManager.handleException) or on methods of the class anewinstance has (Keyboard.dismiss); - 2 (
BatchedBridge.registerCallableModule/registerLazyCallableModule) land on the right methods but in the deprecatedtypes_DEPRECATED/modules/BatchedBridge.d.tsdeclare class MessageQueuerather thanMessageQueue.js. That is the existing instance-member type lookup, whichimport BatchedBridge fromgets too.
- The 255 net-new edges match the 255 refs that left
unresolved_refs.
Dead-code reports (bitwarden, react-native): no claims lost or gained.
Known limitations, both shared with import X from and filed as follow-ups
- First-export guess. The ESM default lookup falls back to the first exported function or class when the default is written
export default class Foo/export default function foo. So a module that exports a helper above its default class resolves to the helper, for an ESM default import today and now forrequire(…).defaulttoo. Repro on main:export function helper()aboveexport default class Foo, andimport Foo from './x'; new Foo()instantiateshelper. A scan found the shape in 7 react-native, 4 topcoder and 1 bitwarden file. None of them is arequire(…).defaulttarget in this corpus, so no edge above depends on it. The fix (reading the declaration forms indefaultExportBinding) changes ESM default imports everywhere and needs its own validation. - Sync retry. When a required module only later gains its
export default,codegraph syncleaves the waiting reffailedwhile a fresh index links it. The failed-ref retry keys on the names the changed files declare, and a default binding's local name (X) is not the declaration's (Foo).import Y from './x'behaves the same way on main.
Test plan
-
__tests__/require-default.test.ts(new, 8 tests):- an ESM-compiled
.defaultand{ default: X }(bitwarden's shape), and a Svelte component, are red on main and green with the fix; - CommonJS
exports.default = fnbeside an anonymousexports.pad = function(the first-export guess would pickpad), the dual export, a CommonJS module with nodefault,export * from, and Pythonfrom .mod import defaultare green on both.
- an ESM-compiled
- Mutations: dropping the depth gate fails the
export *test; dropping theexport defaultgate fails the no-defaultCommonJS test and the require-binding summary. Dropping the language gate changes nothing, because the target-file check already excludes Python; the Python test pins the behavior. -
tsc --noEmit,npm run build, kernel rebuilt after merging main - Full suite, run in parallel before merging main, on a box at 100% CPU (other sessions' suites, about 60–145 node processes): 6347 passed, 153 failed in 69 files. 148 were timeouts, 3
EBUSYtemp-dir cleanups and 2 daemon assertions (mcp-daemon,mcp-writer-lock).- After merging main, the 26 JS/TS resolution files, which include every JS/TS-related file among those 69, were rerun serially with long timeouts: 1281 of 1282 passed.
- The one left is a Python case,
function-ref#1820 "a module global never binds through a shadow". It hits its own 60 s in-file limit even alone, and passes with the limit raised (219 s on this box). - The other 56 failed files (sync/git/daemon/MCP/WAL, and C/C++, Go, PHP, Dart, CFML, Java, Kotlin, Scala, Terraform, Nix and Laravel resolution) were not rerun. The new branch only runs for a JS-family named-
defaultlookup, and none of their fixtures has arequire(…).default.
🤖 Generated with Claude Code