Repository navigation
fix(js,ts): an import written from a jsconfig or tsconfig baseUrl links the file it names - #2649
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 localLoadable(…)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.tsthe project keeps for it.Cause
loadProjectAliasesreturnednullfor a config withbaseUrland nopaths("baseUrl alone isn't an alias … Skip").isExternalImportclassifies a bare specifier from the root config alone. berry and mantis keep their configs invite/, soresolveImportPathreturnednullbefore a nested app's config was ever read.'config','menu-items') was never resolved as a path, even when the specifier names a file./index.jsx, and a route resolves its lazy module as TypeScript. So berry'sviews/dashboard/Default/index.jsxcould not be found even once the specifier resolved.Fix
path-aliases.ts: a config with onlybaseUrlloads, markeddeclaresBaseUrl.aliasAndBaseUrlCandidateslists the most specificpathspattern's targets, then the specifier read from a declaredbaseUrl.pathsalone never reads bare specifiers (TS 4.1+).import-resolver.ts:resolveFromOwnConfig: a specifierisExternalImporttakes for a package is read through the importing file's own config (getNearestAliases, else the root's, keeping the per-app semantics).ResolutionContext.isIndexedFile), becausefileExistsalso answers for folders andnode_modules.isDeclaredPackage, the resolver's existing manifest scope) never binds to a project module. It may bind only to a.d.tsfor 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: inimport { themes } from 'config',themesis config's export, not thethemes/module.isBoundToOutOfRepoImportkeeps theextendsedge of a class whose base is imported throughbaseUrl.importBindingTailparks 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 relinksimport Layout from 'layout/MainLayout'and{ Foo as Bar }. An undeclared name that maps to nothing (expressunder"baseUrl": ".", assync-import-member-retry's fixture writes it) keeps its own tail, and so does a declared package's binding, which root-"*"repos had lost.typescript/tsxextension lists probe/index.jsxlast, as tsc does.docs/design/framework-coverage.mdgets the React Router row update and trap 20. The CHANGELOG bullet is under[Unreleased]→### Fixes.Validation
The two arms were dist snapshots of main
d3a778d0and this branch. Each repo gotcodegraph init -yper arm and ascripts/dump-graph.mjsdiff 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
c5709e3Loadablevalue; the layout ref failed)bec0222DashboardLayout94c37b0[...materialRoutes], imported asapp/views/material-kit/MaterialRoutes, is now read, and each route binds its lazily loaded pageexact-matchtoimport, with the same target, or for a default import the module's file, as relative default imports already do;Loadable(lazy(…))constant to the page.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 itsapp/componentsbarrel to the re-exported declaration.navigatesedges are unchanged.Controls and other
baseUrlreposbaseUrl: "."), 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/namealias 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.baseUrl: "."):import Env from 'env'now links the rootenv.ts, which is what its babel module-resolver (root: ['./']) does too. 2 edges re-resolved fromexact-match0.5, +2 module edges, and the 2 refs that resolved leave the unresolved list.Repos with a root
"*"(found with GitHub code search)styles/imagesmodule refs →src/styles/index.ts,src/images/index.jsroutes,shared,reducersmodule refsutils/store/apimodule refs; one module ref moves off a same-file constant"*"targets (src/shared/*,test/*)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.
requireof one; topcoder had 6,462 under keys likemodule:react."*"maps to no file, such as windscribe'sutils/…, which live inmodules/ext/src, notsrc/. 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.mjsfrom #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
baseUrlimports 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 theapp/componentsbarrel, 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 theimportBindingTailchange,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
__tests__/tsconfig-baseurl-imports.test.ts(new, 18 tests):baseUrl; the declared-package guard; a.d.tskept for a declared package, never a module; indexed files only; a specifier naming nothing;pathswithoutbaseUrl;baseUrl, covering imports, a one-segment module, per-app calls, a lazy route plus its layout, the dependency and built-in guard, andextends;"*"beside asrc/axios/wrapper of theaxiosdependency;__tests__/tsconfig-extends-aliases.test.ts(+5):baseUrl-only loading, inheritedbaseUrl,pathsbeforebaseUrl,"*"withoutbaseUrl, an escaping candidate.catch-all-path-alias, fix(resolution): component and scoped-alias imports link the module file #2528sfc-alias-module-imports, fix(vue): an import through@/or~/in a Vue project links the module's file #2633vue-module-imports, fix(sync): a reference through an import binding links its module when the module appears later #2450sync-import-binding-retry,sync-module-importers,import-emitted-specifier) pass.tsc --noEmitis clean.--maxWorkers=4): 7,333 tests, 6,926 passed, 0 failed, 407 skipped. The first full run caughtsync-import-member-retry's undeclaredexpress: the parking rule then still counted a name that maps to no file, and was narrowed.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