diff --git a/CHANGELOG.md b/CHANGELOG.md index 04df20b0f..1c3159f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. +- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/import-default-as.test.ts b/__tests__/import-default-as.test.ts new file mode 100644 index 000000000..a842d0e19 --- /dev/null +++ b/__tests__/import-default-as.test.ts @@ -0,0 +1,253 @@ +/** + * `import { default as X } from './x'` is the module's default import, spelled + * as a named one. The import mapping recorded it as a named import of an + * export called `default`, which no module declares, so a call, a route or a + * JSX attribute naming `X` never resolved through the import. It fell through + * to matching the name alone, which can bind another file's `X`, and binds + * nothing (or a stranger) when the default export has a name of its own. + * bulletproof-react's router imports its app shell this way: + * `import { default as AppRoot, ErrorBoundary as AppRootErrorBoundary } from + * './routes/app/root'`. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractImportMappings } from '../src/resolution/import-resolver'; +import type { Edge } from '../src/types'; + +const mappings = (source: string, language: Parameters[2] = 'tsx') => + extractImportMappings('src/a.tsx', source, language).map( + (m) => `${m.localName}<${m.exportedName}${m.isDefault ? ' default' : ''}${m.isNamespace ? ' ns' : ''}` + ); + +describe('`{ default as X }` maps to the default import', () => { + it('beside a named import, across lines, with a trailing comma', () => { + expect(mappings(`import {\n default as AppRoot,\n ErrorBoundary as AppRootErrorBoundary,\n} from './routes/app/root';\n`)).toEqual([ + 'AppRoot { + expect(mappings(`import { default as Settings } from './settings';\n`)).toEqual(['Settings { + expect(mappings(`import { defaults as d, defaultTheme as theme } from './theme';\n`)).toEqual([ + 'd { + const sfc = `\n\n\n`; + expect(mappings(sfc, 'svelte')).toEqual(['Card { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-as-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }), + // bulletproof-react's shape: the default export is a const declared + // above it, beside a named ErrorBoundary. + 'src/app/routes/app/root.tsx': `import { Outlet } from 'react-router'; + +export const ErrorBoundary = () => { + return
Something went wrong!
; +}; + +const AppRoot = () => { + return ( +
+ +
+ ); +}; + +export default AppRoot; +`, + // A default export imported under a name that is not its own. + 'src/app/routes/app/settings.tsx': `export default function SettingsRoute() { + return
settings
; +} +`, + 'src/components/card.tsx': `import { memo } from 'react'; + +const Card = memo(() =>
); + +export default Card; +`, + 'src/components/layout.tsx': `export function Layout({ sidebar, children }: { sidebar: unknown; children: unknown }) { + return
{children}
; +} +`, + // Other symbols with the importing file's local names. Matching by name + // alone can only choose between these and the real targets. + 'src/app/legacy.tsx': `export function AppRoot() { + return
legacy shell
; +} + +export function Settings() { + return
legacy settings
; +} +`, + 'src/app/router.tsx': `import { createBrowserRouter } from 'react-router'; + +import { + default as AppRoot, + ErrorBoundary as AppRootErrorBoundary, +} from './routes/app/root'; +import { default as Settings } from './routes/app/settings'; + +export const createAppRouter = () => + createBrowserRouter([ + { + path: '/app', + element: , + ErrorBoundary: AppRootErrorBoundary, + children: [{ path: 'settings', Component: Settings }], + }, + ]); + +export function renderShell() { + return AppRoot(); +} +`, + 'src/app/AppRoutes.tsx': `import { Routes, Route } from 'react-router'; + +import { Layout } from '../components/layout'; +import { default as Settings } from './routes/app/settings'; +import { default as Tile } from '../components/card'; + +export function AppRoutes() { + return ( + + } /> + + ); +} + +export function Gallery() { + return ( + + + + ); +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + const node = (file: string, name: string) => { + const found = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'import'); + expect(found, `${file}: ${name}`).toBeDefined(); + return found!; + }; + /** The edges into `target`, as `kind source-name resolvedBy`. */ + const into = (target: { id: string }, kinds: Edge['kind'][]) => + cg + .getIncomingEdges(target.id) + .filter((e) => kinds.includes(e.kind)) + .map((e) => { + const meta = (e.metadata ?? {}) as { resolvedBy?: string; synthesizedBy?: string }; + return `${e.kind} ${cg.getNode(e.source)!.name} ${meta.resolvedBy ?? meta.synthesizedBy}`; + }) + .sort(); + const route = (name: string) => { + const found = cg.getNodesByKind('route').find((r) => r.name === name); + expect(found, `route ${name}`).toBeDefined(); + return found!; + }; + const renders = (name: string) => + cg + .getOutgoingEdges(route(name).id) + .filter((e) => e.kind === 'references') + .map((e) => { + const target = cg.getNode(e.target)!; + return `${target.filePath}:${target.name} ${(e.metadata as { resolvedBy?: string }).resolvedBy}`; + }) + .sort(); + + it('a call reaches the module’s default export through the import', () => { + expect(into(node('src/app/routes/app/root.tsx', 'AppRoot'), ['calls'])).toEqual([ + 'calls createAppRouter jsx-render', + 'calls renderShell import', + ]); + expect(into(node('src/app/legacy.tsx', 'AppRoot'), ['calls', 'references'])).toEqual([]); + }); + + it('a data router’s `element` and `Component` render the default exports', () => { + expect(renders('/app')).toEqual(['src/app/routes/app/root.tsx:AppRoot import']); + // A child route renders inside its parent's element, its layout. + expect(renders('/app/settings')).toEqual([ + 'src/app/routes/app/root.tsx:AppRoot framework', + 'src/app/routes/app/settings.tsx:SettingsRoute import', + ]); + }); + + it('a JSX route and a JSX attribute name the default export', () => { + expect(renders('/preferences')).toEqual(['src/app/routes/app/settings.tsx:SettingsRoute import']); + // The router's `Component: Settings` is a function reference as well. + expect(into(node('src/app/routes/app/settings.tsx', 'SettingsRoute'), ['references'])).toEqual([ + 'references /app/settings import', + 'references /preferences import', + 'references Gallery import', + 'references createAppRouter import', + ]); + expect(into(node('src/app/legacy.tsx', 'Settings'), ['references'])).toEqual([]); + }); + + it('a JSX tag renders the default-exported component it was imported as', () => { + expect(into(node('src/components/card.tsx', 'Card'), ['calls'])).toEqual(['calls Gallery jsx-render']); + }); + + it('the named import beside it is unchanged', () => { + expect(into(node('src/app/routes/app/root.tsx', 'ErrorBoundary'), ['references'])).toEqual([ + 'references createAppRouter import', + ]); + }); + + it('the import binding links the module, as a default import’s does', () => { + const file = cg.getNodesInFile('src/app/router.tsx').find((n) => n.kind === 'file')!; + const bindings = cg + .getOutgoingEdges(file.id) + .filter((e) => e.kind === 'imports') + .map((e) => { + const meta = e.metadata as { refName?: string; resolvedBy?: string }; + const target = cg.getNode(e.target)!; + return `${meta.refName} → ${target.kind} ${target.filePath} ${meta.resolvedBy}`; + }) + .filter((s) => !s.startsWith('.')) + .sort(); + expect(bindings).toEqual([ + 'AppRoot → file src/app/routes/app/root.tsx import', + 'AppRootErrorBoundary → function src/app/routes/app/root.tsx import', + 'Settings → file src/app/routes/app/settings.tsx import', + ]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 8847ef653..e8c5e6a4e 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1019,7 +1019,9 @@ function extractJSImports(content: string): ImportMapping[] { localName: aliasMatch[2]!, exportedName: aliasMatch[1]!, source: source!, - isDefault: false, + // `{ default as X }` is the default import spelled as a named one; + // no module declares an export named `default` to find. + isDefault: aliasMatch[1] === 'default', isNamespace: false, }); } else if (name) {