Skip to content

fix(js,ts): import { default as X } is the module's default import - #2412

Merged
colbymchenry merged 6 commits into
mainfrom
claude/dreamy-perlman-7c9188
Oct 7, 2026
Merged

colbymchenry merged 6 commits into
mainfrom
claude/dreamy-perlman-7c9188

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

alan2207/bulletproof-react's router (apps/react-vite/src/app/router.tsx) imports its app shell as:

import {
  default as AppRoot,
  ErrorBoundary as AppRootErrorBoundary,
} from './routes/app/root';

extractJSImports recorded AppRoot as { exportedName: 'default', isDefault: false }, a named import of an export called default. No module declares one, so findExportedSymbol found nothing, resolveImport returned null for AppRoot, and every reference to it fell through to name matching. In bulletproof-react the route /app binds to AppRoot by exact-match. When another file declares a symbol with the same name, the reference can bind to that one instead. The new test's fixture reproduces this on main:

  • the route /app → legacy.tsx:AppRoot
  • renderShell()'s call → legacy.tsx:AppRoot
  • /preferences, Component: Settings and sidebar={Settings} → legacy.tsx:Settings instead of the default export SettingsRoute

Fix

extractJSImports now records an aliased named import whose imported name is default as the default import (isDefault: true), which is the mapping import X from already gets.

I fixed the mapping rather than teaching findExportedSymbol to read exportedName === 'default' as the default, because then every other reader of ImportMapping.isDefault agrees with it too:

  • resolveViaImport → findExportedSymbol takes the default-export path: export default …, a binding exported as default, a component file, or CommonJS module.exports =.
  • resolveModuleImportToFile links the binding's own imports reference to the module file, as it does for a default import.
  • The other readers now treat it as a default import:
    • jsx-render's default-import fallback: a tag no node is named for renders the imported file's one component;
    • expo-modules: export default requireNativeModule(…);
    • tier-synthesizer: a default-exported HTTP client;
    • react-router-synthesizer: a default-exported styled(Link);
    • the zustand factory check.

Svelte, Vue and Astro script blocks go through the same function, so they're covered.

Checked and left unchanged:

Overlap with #2400

layoutComponent in frameworks/react.ts works around this bug. When resolveImport fails for a layout's name, it reads the module of a mapping with isDefault || exportedName === 'default'. With this fix resolveImport succeeds first for { default as X }. That fallback is now reached only when the default export can't be resolved, or for require(…).default, so I left it as is.

Validation

main at 5ace306b against this branch. Both arms had the native kernel staged, since TS/JS are kernel-routed as in a release. Each repo was indexed fresh (init -y) and compared with scripts/dump-graph.mjs.

repo nodes edges (−/+) refs (−/+) what changed
alan2207/bulletproof-react same −4 / +4 same the same 4 sites, re-resolved (below)
bitwarden/clients same −0 / +2 −2 / +0 2 failed imports now link their module (below)
bradtraversy/proshop_mern (control) byte-identical
leerob/next-saas-starter (control) byte-identical
t3-oss/create-t3-turbo (control) byte-identical

In bulletproof-react:

  • The route /app → AppRoot now resolves by import instead of exact-match. The target is the same.
  • The imports bindings AppRoot, DiscussionRoute and DiscussionsRoute (the last two from the discussions route tests) now link their module file instead of the function, by import instead of exact-match. Default imports already link this way.

In bitwarden, two Storybook files import { default as baseComponentDefinition } from './access-selector.stories'. That default export is an anonymous object, so these were failed refs. They now link the module file.

No edge is lost. Every edge removed in bulletproof-react is the same source site, re-resolved. Dead-code reports (buildDeadCodeReport, with and without exported symbols) are identical on bulletproof-react and bitwarden.

I surveyed the local validation kits (about 100 JS/TS repos). Project-local { default as X } imports appear only in these two repos, plus one string fixture in qwik. Imports from a package, like { default as dayjs } from 'dayjs', don't change.

Tests

__tests__/import-default-as.test.ts:

  • Mapping cases:
    • a multi-line import with a trailing comma beside a named import (bulletproof's shape);
    • { default as X } alone;
    • import type { default as X } and import { type default as X };
    • import A, { default as B };
    • defaults and defaultTheme stay named;
    • Svelte and Vue script blocks.
  • An indexed React Router fixture with decoys: legacy.tsx declares its own AppRoot and Settings. It checks:
    • a call;
    • a data router's element and Component;
    • a JSX <Route element>;
    • a JSX attribute (sidebar={Settings});
    • a JSX tag of a memo component imported under another name;
    • the named import beside it;
    • the binding's own imports edge.

Results:

  • On main at 31c3328d, 8 of the new test's 10 cases fail. All 10 pass with the fix. The 2 that pass on main guard behaviour that must not change.
  • The full suite (npx vitest run --testTimeout=120000) ran on the Windows dev box while another workload held it at 100% CPU: 6345 passed and 18 failed.
  • On 31c3328d with the fix, the 17 related test files pass serially, 253 tests in all. They cover imports, React Router, CommonJS, Expo modules, JSX-child disambiguation, cross-tier steps, stores, sync import retries and kernel TS/JS parity.
  • The new test file type-checks under the repo's strict tsconfig.

The A/B arms were built on 5ace306b. The branch was then rebased onto 31c3328d (#2403), which changes Liquid sync and reference tails, not JS/TS resolution.

Since then, main has merged #2423 (React Router JSX index routes and layouts) and #2424 (the JSX render pass on Node 22). I re-ran the bulletproof-react A/B on 855eca26 and got the same 4 edges re-resolved at the same sites, with nothing else changed. I also re-ran import-default-as, react-router, react-router-nested-lazy, jsx-render-work, js-local-binding-backtracking, jsx-child-disambiguation and import-type-modifier on the merged branch: 100 tests pass.

Not in this PR

Two separate gaps turned up along the way. I've filed each as its own task.

  • JSX render edges follow a tag's name, not its import.
    • jsxChild takes a single same-named candidate even when the file imports the tag from a module that doesn't declare it.
    • With several candidates, it breaks the tie by the import's file, and a barrel file never declares the component.
    • In bulletproof-react, 179 of the 559 jsx-render edges cross from one app into another. For example, react-vite's CreateComment renders nextjs-app's Button, Textarea and Form.
    • <Settings /> after import { default as Settings } from './settings' renders another file's Settings when one exists. import Settings from does the same, so this isn't specific to this fix.
  • const X = require('./x').default still links nothing when ./x is a compiled ES module. For example, bitwarden's new OsBiometricsServiceMac(…) in main-biometrics.service.ts. Fixing it has to keep a CommonJS module's own exports.default working.

🤖 Generated with Claude Code

colbymchenry and others added 6 commits October 7, 2026 00:27
bulletproof-react's router imports its app shell as
`import { default as AppRoot, ErrorBoundary as AppRootErrorBoundary }
from './routes/app/root'`. The import mapping recorded AppRoot as a
named import of an export called `default`, which no module declares,
so resolveViaImport found nothing for it and every reference fell
through to name matching. The route /app bound to AppRoot by
exact-match, and in a project where another file declares an AppRoot
(or a default export is imported under a name another file uses) the
call, route or JSX attribute bound to that file's symbol instead.

The mapping now records `{ default as X }` as the default import, the
same as `import X from`, so every reader of isDefault agrees with it:
the import resolver finds the module's default export, the binding's
own `imports` reference links the module file as a default import's
does, and the JSX-child, Expo native-module, HTTP-client and styled
Link lookups read it as the default import. Re-exports
(`export { default as X } from`) were already chased as the default.
`require('./x').default` is unchanged: `.default` there is a property
of module.exports, which a CommonJS module can set by name.

alan2207/bulletproof-react: 4 edges re-resolved at the same sites (the
route /app reaches AppRoot by import; three binding imports link their
module file). bitwarden/clients: 2 failed Storybook imports now link
their module. bradtraversy/proshop_mern, leerob/next-saas-starter and
t3-oss/create-t3-turbo: byte-identical. Dead-code reports 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