Files
codegraph/__tests__/import-default-as.test.ts
Colby Mchenry f261fda5c2 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>
2026-10-07 08:58:25 +00:00

254 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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',
]);
});
});