Skip to content

fix(js,ts): an import written from a jsconfig or tsconfig baseUrl links the file it names - #2649

Merged
colbymchenry merged 1 commit into
mainfrom
claude/hungry-babbage-748320
Oct 19, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/hungry-babbage-748320

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

JavaScript and TypeScript imports written from a jsconfig/tsconfig baseUrl (import MainLayout from 'layout/MainLayout' under "baseUrl": "src"), or through a catch-all "paths": { "*": ["./src/*"] }, now resolve to the file they name, read with the config of the app the importing file belongs to. CRA and Vite admin templates (codedthemes' berry and mantis, uilibrary's matx) write nearly every cross-file import this way.

Before this change, each such import was taken for an npm package. The import stayed unresolved and its names linked by bare-name matching: berry's and mantis's vite components rendered the repo's other app's same-named components. React Router routes loaded through lazy(() => import('views/x')) bound to the local Loadable(…) value instead of the page and its layout.

A package the importing app's package.json (or an enclosing one) declares, or a Node built-in, never binds to a project module this way, only to a .d.ts the project keeps for it.

Cause

  • loadProjectAliases returned null for a config with baseUrl and no paths ("baseUrl alone isn't an alias … Skip").
  • isExternalImport classifies a bare specifier from the root config alone. berry and mantis keep their configs in vite/, so resolveImportPath returned null before a nested app's config was ever read.
  • The module ref of a one-segment specifier ('config', 'menu-items') was never resolved as a path, even when the specifier names a file.
  • The TypeScript extension list had no /index.jsx, and a route resolves its lazy module as TypeScript. So berry's views/dashboard/Default/index.jsx could not be found even once the specifier resolved.

Fix

  • path-aliases.ts: a config with only baseUrl loads, marked declaresBaseUrl. aliasAndBaseUrlCandidates lists the most specific paths pattern's targets, then the specifier read from a declared baseUrl. paths alone never reads bare specifiers (TS 4.1+).
  • import-resolver.ts:
    • resolveFromOwnConfig: a specifier isExternalImport takes for a package is read through the importing file's own config (getNearestAliases, else the root's, keeping the per-app semantics).
      • It binds only to a file the index holds (new ResolutionContext.isIndexedFile), because fileExists also answers for folders and node_modules.
      • A declared package or Node built-in (new isDeclaredPackage, the resolver's existing manifest scope) never binds to a project module. It may bind only to a .d.ts for it, which keeps fix(js,ts): a catch-all or node_modules path alias no longer makes package imports look local #2437's "*": ["./typings/*"] case (legacy-widgets → typings/legacy-widgets.d.ts).
    • isExternalImport: a root catch-all "*" (empty prefix) no longer marks every specifier local in the default mode either. The fallback above handles it, with the package guard.
    • isJsBareModuleRef: the module ref of a one-segment import links the file its specifier resolves to, as a path-shaped one already does (fix(resolution): component and scoped-alias imports link the module file #2528 covered @scope/pkg). A name some import binds locally still resolves as that binding: in import { themes } from 'config', themes is config's export, not the themes/ module.
    • isBoundToOutOfRepoImport keeps the extends edge of a class whose base is imported through baseUrl.
    • importBindingTail parks a binding under its module when the app's config maps the specifier to a file the index holds, the rule fix(sync): a reference through an import binding links its module when the module appears later #2450 set for relative imports. So a sync that edits the module relinks import Layout from 'layout/MainLayout' and { Foo as Bar }. An undeclared name that maps to nothing (express under "baseUrl": ".", as sync-import-member-retry's fixture writes it) keeps its own tail, and so does a declared package's binding, which root-"*" repos had lost.
    • The typescript/tsx extension lists probe /index.jsx last, as tsc does.
  • Docs: docs/design/framework-coverage.md gets the React Router row update and trap 20. The CHANGELOG bullet is under [Unreleased] → ### Fixes.

Validation

The two arms were dist snapshots of main d3a778d0 and this branch. Each repo got codegraph init -y per arm and a scripts/dump-graph.mjs diff keyed on natural keys. Removed edges were triaged by site (source, kind, line, col). Both arms use the wasm extraction path; the change is resolution-only and the kernel only extracts.

The three admin templates

repo import edges failed imports that name a project file routes
codedthemes/berry-free-react-admin-template c5709e3 407 → 634 194 → 0 all 9 bind their page and layout (before: the local Loadable value; the layout ref failed)
codedthemes/mantis-free-react-admin-template bec0222 373 → 637 190 → 0 8 bind their page and DashboardLayout
uilibrary/matx-react 94c37b0 366 → 465 96 → 0 7 → 21: [...materialRoutes], imported as app/views/material-kit/MaterialRoutes, is now read, and each route binds its lazily loaded page
  • Edge totals: berry −272 +528, mantis −210 +576, matx −161 +276. No site lost its edge.
  • Where the removed edges went:
    • named imports went from exact-match to import, with the same target, or for a default import the module's file, as relative default imports already do;
    • JSX renders that crossed into the repo's other app now land on the same app's component: berry 37 (vite → remix) and mantis 45 (vite → next). Every added JSX render (55, 55 and 2) stays in its own app;
    • route refs moved from the local Loadable(lazy(…)) constant to the page.
  • Precision: every new import-resolved edge's target is the file its specifier names under the app's config. berry 457/457, mantis 507/507; matx 216 direct, plus 42 through its app/components barrel to the re-exported declaration.
  • Navigation: navigates edges are unchanged.

Controls and other baseUrl repos

  • Byte-identical: bradtraversy/proshop_mern, leerob/next-saas-starter (baseUrl: "."), t3-oss/create-t3-turbo, excalidraw (baseUrl: "."), alan2207/bulletproof-react, jasontaylordev/CleanArchitecture, minimal-ui-kit/material-kit-react, taniarascia/takenote, gothinkster/angular-realworld-example-app, gothinkster/node-express-realworld-example-app. For excalidraw and takenote, the @scope/name alias module links an earlier version of this change added are already on main via fix(resolution): component and scoped-alias imports link the module file #2528.
  • obytes/react-native-template-obytes (baseUrl: "."): import Env from 'env' now links the root env.ts, which is what its babel module-resolver (root: ['./']) does too. 2 edges re-resolved from exact-match 0.5, +2 module edges, and the 2 refs that resolved leave the unresolved list.
  • In the three templates, 3 refs each that stay failed now wait under their module's key, because the config maps their module to an indexed file.

Repos with a root "*" (found with GitHub code search)

repo edges other changes
giantswarm/happa +47 styles/images module refs → src/styles/index.ts, src/images/index.js
topcoder-platform/community-app +4 routes, shared, reducers module refs
JounQin/react-hackernews −1 +14 utils/store/api module refs; one module ref moves off a same-file constant
kentcdodds/jest-cypress-react-babel-webpack +4 through the 2nd and 3rd "*" targets (src/shared/*, test/*)
hypha-dao/dho-web-client, Windscribe/browser-extension, CodeForAfrica/sensors.AFRICA 0 only parked tails, below

Only the parked tails of failed refs move, and every one moves the same way: from a module key back to the ref's own name.

  • Most belong to a declared package, or to a require of one; topcoder had 6,462 under keys like module:react.
  • The rest are bare names the root "*" maps to no file, such as windscribe's utils/…, which live in modules/ext/src, not src/. They resolve on neither arm.

That is importBindingTail's stated rule, and repos without a catch-all already behave that way. These keys change which syncs retry a ref, never an edge.

With node_modules installed

stub-nm.mjs from #2437's kit creates a folder per declared package and imported specifier. On berry, mantis, matx, happa, topcoder, dho-web, windscribe, next-saas and expo-obytes, this branch's delta is identical with and without the stubs, and the stubs move neither arm.

Sync convergence

On berry, mantis and matx I took the 15 files baseUrl imports resolve to and ran two scenarios: indexed without them then added back, and indexed with a stub in their place then edited back. Each synced graph was compared with a fresh index. 5 of 6 are byte-identical.

matx's edit scenario leaves one edge: MatxSuspense.jsx's { MatxLoading } imported from the app/components barrel, which re-exports { default as MatxLoading }. The same import written relatively (from '.') fails the same way on main and on this branch, so it is a pre-existing barrel gap (follow-up below). Without the importBindingTail change, import runIt from 'handlers/run' and { Widget as W } would also stay unlinked after an edit; the new test locks both.

Cost

Interleaved process CPU time, median of 3, measured against 6071c9e4: happa 25.7 → 26.5 s, excalidraw 32.5 → 32.6 s, berry 5.0 → 5.3 s (berry links ~500 more edges). Wall time is unchanged.

Tests

Follow-up (not in this PR)

A sync doesn't relink a named import through a barrel that re-exports a renamed default (export { default as MatxLoading } from './MatxLoading') when the module later gains that default through an edit. It's the same with a relative barrel import, on main too.

The binding is parked under its own name (MatxLoading), which the edited module never declares (function Loading). It is not parked under the barrel's module key either. I'll take it next as its own PR.

🤖 Generated with Claude Code

…ks the file it names

CRA and Vite admin templates (codedthemes' berry and mantis, uilibrary's
matx) set `"baseUrl": "src"` or `"paths": { "*": ["./src/*"] }` in a
jsconfig and write every cross-file import as `layout/MainLayout`. The
import resolver took each one for an npm package: loadProjectAliases
returned null for a config with a baseUrl and no paths, and
isExternalImport classifies a bare specifier from the root config alone,
before a nested app's config is read. The imports stayed unresolved,
their names linked by bare-name matching (vite components rendering the
remix app's namesakes), and React Router routes loaded through
`lazy(() => import('views/x'))` bound to the local Loadable value.

- path-aliases: a config with only `baseUrl` loads, marked
  `declaresBaseUrl`; aliasAndBaseUrlCandidates lists the most specific
  `paths` pattern's targets, then the specifier read from a declared
  baseUrl (paths alone never reads bare specifiers, as in TS 4.1+).
- import-resolver: a specifier isExternalImport takes for a package is
  read through the importing file's own config (nearest, else the root's)
  and binds only to a file the index holds (`fileExists` also answers for
  folders and node_modules). A package the file's package.json or an
  enclosing one declares, or a Node built-in, never binds to a project
  module that way, only to a `.d.ts` the project keeps for it (#2437's
  `"*": ["./typings/*"]`). A root catch-all `"*"` no longer marks every
  specifier local in isExternalImport's default mode either.
- The module ref of a one-segment import (`'config'`) links the file its
  specifier resolves to, as a path-shaped one already does (#2528).
- isBoundToOutOfRepoImport keeps the `extends` edge of a class whose base
  is imported through baseUrl.
- importBindingTail parks a binding under its module when the app's
  config maps the specifier to an indexed file, so a sync that edits the
  module relinks `import Layout from 'layout/MainLayout'` and
  `{ Foo as Bar }` (#2450's rule for relative imports). An undeclared name
  that maps to nothing (`express` under `"baseUrl": "."`) and a declared
  package keep their own tail, which root-"*" repos had lost.
- The TypeScript and TSX extension lists probe `/index.jsx` last, as tsc
  does: a route resolves its lazy module as TypeScript.

Validated against main 6071c9e on 21 repos (wasm path): every new
import-resolved edge lands in the file its specifier names; failed
imports naming a project file go 194/190/96 -> 0 on berry/mantis/matx;
their routes bind their lazily loaded pages and layouts (matx 7 -> 21
routes); 37 + 45 cross-app JSX renders move to the app's own components.
proshop, next-saas-starter, create-t3-turbo, excalidraw and six more are
byte-identical; root-"*" repos change only parked tails plus module
links. Same deltas with stubbed node_modules. Sync after late adds and
edits equals a fresh index, except one pre-existing barrel-edit gap that
relative imports share.

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