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

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>
This commit is contained in:
Colby Mchenry
2026-10-07 08:58:25 +00:00
committed by GitHub
parent 388b25279a
commit f261fda5c2
3 changed files with 257 additions and 1 deletions
+1
View File
@@ -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 `<Route {...rest}>`, 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 `<Link to="/counter">` links led nowhere. Each route links to the page its `element` renders, past a guard like `<ProtectedRoute>` or `<AdminWrapper>`, 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 `<Route index>` the way route objects do: inside `<Route path="/" element={<Layout />}>`, `<Route index element={<Home />} />` 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 `<Route element={<RequireAuth />}>`. Before, the index route was never read, so `/` led to the layout itself and a `<Route path="/dashboard">` 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 `<Suspense fallback={<Loader />}>`, 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
+253
View File
@@ -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<typeof extractImportMappings>[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<default default',
'AppRootErrorBoundary<ErrorBoundary',
]);
});
it('alone, type-only, and next to a default binding', () => {
expect(mappings(`import { default as Settings } from './settings';\n`)).toEqual(['Settings<default default']);
expect(mappings(`import type { default as Config } from './config';\n`)).toEqual(['Config<default default']);
expect(mappings(`import { type default as Config } from './config';\n`)).toEqual(['Config<default default']);
expect(mappings(`import Main, { default as Again, helper } from './main';\n`)).toEqual([
'Main<default default',
'Again<default default',
'helper<helper',
]);
});
it('only for the export named exactly `default`', () => {
expect(mappings(`import { defaults as d, defaultTheme as theme } from './theme';\n`)).toEqual([
'd<defaults',
'theme<defaultTheme',
]);
});
it('in a Svelte or Vue script block too', () => {
const sfc = `<script>\n import { default as Card } from './Card.svelte';\n</script>\n\n<Card />\n`;
expect(mappings(sfc, 'svelte')).toEqual(['Card<default default']);
expect(mappings(sfc, 'vue')).toEqual(['Card<default default']);
});
});
describe('a symbol imported as `{ default as X }`', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-as-'));
const files: Record<string, string> = {
'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 <div>Something went wrong!</div>;
};
const AppRoot = () => {
return (
<main>
<Outlet />
</main>
);
};
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 <section>settings</section>;
}
`,
'src/components/card.tsx': `import { memo } from 'react';
const Card = memo(() => <div className="card" />);
export default Card;
`,
'src/components/layout.tsx': `export function Layout({ sidebar, children }: { sidebar: unknown; children: unknown }) {
return <div>{children}</div>;
}
`,
// 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 <div>legacy shell</div>;
}
export function Settings() {
return <div>legacy settings</div>;
}
`,
'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: <AppRoot />,
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 (
<Routes>
<Route path="/preferences" element={<Settings />} />
</Routes>
);
}
export function Gallery() {
return (
<Layout sidebar={Settings}>
<Tile />
</Layout>
);
}
`,
};
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',
]);
});
});
+3 -1
View File
@@ -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) {