Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. Re-index JavaScript and TypeScript projects after upgrading.

## [1.6.2] - 2026-10-03

Expand Down
274 changes: 274 additions & 0 deletions __tests__/default-export-declaration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/**
* A default import is the declaration the module's `export default` statement
* names, wherever it sits in the file — not the first exported function.
*
* A React Router 6.4+ data-router page exports its loader or action above the
* page component (`export function loader() {…}` then `export default function
* Vans() {…}`), and `import Vans, { loader as vansLoader } from './Vans'` bound
* `Vans` to `loader`: the route `/vans` rendered the loader, and every call
* through the default import went there too. A component file that exports a
* styled or memo component lost its default function or binding to that
* component, and an anonymous default (`export default function () {…}`,
* `export default () => …`) took whatever exported function came first — even
* one nested inside the default itself.
*/
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';

let root = '';
let cg: CodeGraph;

const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'vanlife', private: true, dependencies: { react: '^18', 'react-router-dom': '^6.4' } }),
'src/index.jsx': `import { RouterProvider, createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom"
import Vans, { loader as vansLoader } from "./pages/Vans/Vans"
import HostVans, { loader as hostVansLoader } from "./pages/Host/HostVans"
import Login, { action as loginAction } from "./pages/Login"

const router = createBrowserRouter(createRoutesFromElements(
<Route path="/">
<Route path="login" element={<Login />} action={loginAction} />
<Route path="vans" element={<Vans />} loader={vansLoader} />
<Route path="host/vans" element={<HostVans />} loader={hostVansLoader} />
</Route>
))

export default function App() {
return <RouterProvider router={router} />
}
`,
'src/api.js': `export function getVans() {
return fetch("/api/vans")
}
`,
'src/pages/Vans/Vans.jsx': `import { useLoaderData, defer } from "react-router-dom"
import { getVans } from "../../api"

export function loader() {
return defer({ vans: getVans() })
}

export default function Vans() {
const dataPromise = useLoaderData()
function renderVanElements(vans) {
return vans.map((van) => <h3 key={van.id}>{van.name}</h3>)
}
return <div>{renderVanElements(dataPromise.vans)}</div>
}
`,
'src/pages/Host/HostVans.jsx': `import { useLoaderData } from "react-router-dom"
import { getVans } from "../../api"

export const loader = async () => getVans()

export default function HostVans() {
return <ul>{useLoaderData().length}</ul>
}
`,
'src/pages/Login.jsx': `import { useActionData } from "react-router-dom"

export async function action({ request }) {
return request.formData()
}

export default function Login() {
const data = useActionData()
return <form>{data}</form>
}
`,
// A Next.js API route: a helper exported above the default handler.
'src/api/vans.ts': `export function listVans() {
return []
}

export default async function handler(req: unknown, res: { json(v: unknown): void }) {
res.json(listVans())
}
`,
'src/store.ts': `export function createStore() {
return {}
}

export default class Store {
read() {
return 1
}
}
`,
// A regex with a backtick inside a template literal's interpolation
// (outline's headingToSlug): a string masker loses step there and blanked
// the statement below it.
'src/slug.ts': `export function escapeHtml(text: string) {
return text
}

function safeSlugify(text: string) {
return \`h-\${escapeHtml(text).replace(/[\`]/g, "")}\`
}

export default function headingToSlug(text: string, index = 0) {
return index === 0 ? safeSlugify(text) : \`\${safeSlugify(text)}-\${index}\`
}
`,
// A line break between the statement and the declaration it writes.
'src/stream.js': `export function emit() {
return 1
}

export default
function* stream() {
yield emit()
}
`,
// An exported styled component above the default function, or the binding.
'src/components/Title.tsx': `import styled from "styled-components"

export const Heading = styled.h1\`
font-size: 2rem;
\`

export default function Title() {
return <Heading>Vans</Heading>
}
`,
'src/components/Banner.tsx': `import styled from "styled-components"

export const Strip = styled.div\`
display: flex;
\`

const Banner = () => <Strip />

export default Banner
`,
// Anonymous defaults name no declaration of their own.
'src/anonymous.js': `export function helper() {
return 1
}

export default function () {
function inner() {
return helper()
}
return inner()
}
`,
'src/arrow.jsx': `export function useThing() {
return 1
}

export default () => {
const handle = () => useThing()
return <button onClick={handle} />
}
`,
// An expression default keeps the class it instantiates.
'src/service.ts': `export class Service {
run() {
return 1
}
}

export default new Service()
`,
'src/use.tsx': `import handler from "./api/vans"
import Store from "./store"
import headingToSlug from "./slug"
import stream from "./stream"
import Title from "./components/Title"
import Banner from "./components/Banner"
import runAnonymous from "./anonymous"
import Arrow from "./arrow"
import service from "./service"

export function consume() {
handler({}, { json() {} })
new Store().read()
headingToSlug("Vans")
stream()
Title()
Banner()
runAnonymous()
Arrow()
service.run()
}
`,
};

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-default-export-'));
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 });
});

/** `file:name` of every node `consume` calls or instantiates. */
function consumed(): string[] {
const consume = cg.getNodesInFile('src/use.tsx').find((n) => n.name === 'consume')!;
return cg
.getOutgoingEdges(consume.id)
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => cg.getNode(e.target)!)
.map((n) => `${n.filePath}:${n.name}`)
.sort();
}

describe('a default import of a module that exports something above its default', () => {
it('binds each data-router route to the page component, not its loader or action', () => {
const bindings = cg
.getNodesByKind('route')
.flatMap((r) =>
cg
.getOutgoingEdges(r.id)
.filter((e) => e.kind === 'references')
.map((e) => `${r.name} -> ${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`)
)
.sort();
expect(bindings).toEqual([
'/host/vans -> src/pages/Host/HostVans.jsx:HostVans',
'/login -> src/pages/Login.jsx:Login',
'/vans -> src/pages/Vans/Vans.jsx:Vans',
]);
});

it('calls the default-exported function or class the statement declares', () => {
const targets = consumed();
expect(targets).toContain('src/api/vans.ts:handler');
expect(targets).toContain('src/store.ts:Store');
expect(targets).toContain('src/slug.ts:headingToSlug');
expect(targets).toContain('src/stream.js:stream');
expect(targets).not.toContain('src/api/vans.ts:listVans');
expect(targets).not.toContain('src/store.ts:createStore');
expect(targets).not.toContain('src/slug.ts:escapeHtml');
expect(targets).not.toContain('src/stream.js:emit');
});

it('prefers what the statement exports to an exported component above it', () => {
const targets = consumed();
expect(targets).toContain('src/components/Title.tsx:Title');
expect(targets).toContain('src/components/Banner.tsx:Banner');
expect(targets).not.toContain('src/components/Title.tsx:Heading');
expect(targets).not.toContain('src/components/Banner.tsx:Strip');
});

it('binds an anonymous default to nothing, not to an exported function beside or inside it', () => {
const targets = consumed();
for (const wrong of ['src/anonymous.js:helper', 'src/anonymous.js:inner', 'src/arrow.jsx:useThing', 'src/arrow.jsx:handle']) {
expect(targets).not.toContain(wrong);
}
});

it('still finds the class behind an expression default', () => {
expect(consumed()).toContain('src/service.ts:run');
});
});
Loading