Skip to content

fix(js,ts): require('./x').default reaches an ES module's default export - #2433

Merged
colbymchenry merged 4 commits into
mainfrom
claude/bold-sinoussi-dbaf37
Oct 7, 2026
Merged

colbymchenry merged 4 commits into
mainfrom
claude/bold-sinoussi-dbaf37

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

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 = X sets 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's from .mod import default is untouched.
  • Only the module the require names (depth === 0). export * from never 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 default statement. A CommonJS module's anonymous exports.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 express normalizeType bug class from fix(js): resolve calls through CommonJS require bindings #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 19f91e2 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(…).default binding 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 for Keyboard.dismiss. For JSTimers and Animated it is the exported value, whose members are chosen at run time;
    • 28 keep the same target and gain import metadata;
    • 1 moved from the log(…) signature in a Flow type to the log implementation.
  • Added edges, checked against each target file's export default statement:
    • 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 a new instance has (Keyboard.dismiss);
    • 2 (BatchedBridge.registerCallableModule / registerLazyCallableModule) land on the right methods but in the deprecated types_DEPRECATED/modules/BatchedBridge.d.ts declare class MessageQueue rather than MessageQueue.js. That is the existing instance-member type lookup, which import BatchedBridge from gets 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 for require(…).default too. Repro on main: export function helper() above export default class Foo, and import Foo from './x'; new Foo() instantiates helper. A scan found the shape in 7 react-native, 4 topcoder and 1 bitwarden file. None of them is a require(…).default target in this corpus, so no edge above depends on it. The fix (reading the declaration forms in defaultExportBinding) changes ESM default imports everywhere and needs its own validation.
  • Sync retry. When a required module only later gains its export default, codegraph sync leaves the waiting ref failed while 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 .default and { default: X } (bitwarden's shape), and a Svelte component, are red on main and green with the fix;
    • CommonJS exports.default = fn beside an anonymous exports.pad = function (the first-export guess would pick pad), the dual export, a CommonJS module with no default, export * from, and Python from .mod import default are green on both.
  • Mutations: dropping the depth gate fails the export * test; dropping the export default gate fails the no-default CommonJS 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 EBUSY temp-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 Caller/impact graph misses methods passed as first-class references (callbacks), e.g. executor.submit(obj.method, …) #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-default lookup, and none of their fixtures has a require(…).default.

🤖 Generated with Claude Code

colbymchenry and others added 2 commits October 7, 2026 05:54
…xport

`const X = require('./x').default` and `const { default: X } = require('./x')`
map to a named import of `default`. That is right for a CommonJS module that
sets the property itself (`exports.default = fn`, or the dual
`module.exports = X; module.exports.default = X`), but a module written as an
ES module and compiled to CommonJS sets it to its default export, which is no
named export. References through the binding stayed unresolved (bitwarden's
`new OsBiometricsServiceMac(...)`) or fell to name matching: the local
variable holding the module, or a namesake method in another package.

findExportedSymbolWalk now falls back to the module's ESM default export when
the named lookup finds no `default`. Only for JS-family refs, only in the
module the require names (`export * from` forwards no default), and only when
that module has an ESM default export at all: a single-file component, or an
`export default` statement. A CommonJS module's `exports.x = function`
declarations are flagged exported, so without that gate the
first-exported-function guess would invent a default for it.

bitwarden +2 edges (the two failed `instantiates` refs). react-native +872
-617: all 617 re-resolved at the same site (541 from the local require
binding to the real function or class, 47 from a namesake method in another
package to the right module, 28 metadata only, 1 from a type signature to the
implementation), 870 of 872 added edges on the declared default or what it
holds. express, eslint, mocha, koa, body-parser, fastify, topcoder,
proshop_mern, next-saas-starter and create-t3-turbo byte-identical. Dead-code
claims unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant