Files
codegraph/__tests__/default-export-declaration.test.ts
T
Colby MchenryandClaude Opus 5.5 ed6b88e53a fix(js): an export default line inside a template literal no longer names the default (#3068)
statedDefaultExport reads a JS/TS file's own export default statement
by scanning comment-stripped source for line-initial `export default`
heads. Strings are deliberately not masked there (a masker that loses
step on nested templates blanked real statements), and a head counted
when it named a node of the file, the first such head winning. A code
generator's template text breaks that: in

  export function App() {}  export function Main() {}
  export const template = `
  export default App
  `;
  export default Main;

the template's line names the real App, so `import X from './gen'`
bound to App instead of Main (issue #2968). A template holding
`export default () => …` also made a module whose real default is an
expression read as anonymous, which keeps every guess away.

templateLiteralTextAt (strip-comments.ts) scans the source once for
template-literal text: it follows `${…}` nesting (a template inside an
interpolation, a brace inside a regex there), and skips comments,
quoted strings and regex literals, which may hold a backtick; a `/`
after a value divides, anywhere else it opens a regex. A head inside
template text is now skipped. When some template never closes the scan
vouches for nothing and every head counts, as before. Nothing else
changes: a head outside template text is read exactly as it was.

Validation: default-export-declaration.test.ts gains the issue's file,
a template returned by a function behind a nested template, one passed
at module scope and one in a class field, a template's anonymous
default above an expression default, and controls (a regex and a string
holding a backtick, a division after a parenthesis, and a file whose
scan can't close a template); strip-comments.test.ts tests the scan
directly. Red with main's resolver: 2 of 10 failed; green: all pass.
Checked against tree-sitter on every line-initial head of 4,191
JS/TS files in 16 corpora (outline, excalidraw, vben, bitwarden apps,
mastodon, shadcn docs, zod, bulletproof-react, express, astro, react,
trezor-suite, this repo's src, tests and ui): of 4,149 real export
default statements the scan puts none inside template text, and all
322 heads it does place there are template text. A/B on excalidraw
d3a9c2a1, vben 61c07936, astro e3bec3d2 (packages/astro/src and
packages/integrations), with gin-gonic/gin 0f09c3a9 and psf/requests
3c7baaa0 as controls, both arms on main 4b0f72b7 through the native
kernel (and again on 66449255 through wasm): all six byte-identical,
since no default import there is decided by template text; index time
unchanged.

Not in this change: hasEsmDefaultExport, which only gates
require('./x').default, still reads the raw text.

Reimplements PR #3042 by @johnhldiver-beep.

Fixes #2968

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:55:21 +00:00

453 lines
14 KiB
TypeScript

/**
* 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. `require('./x').default` of an ES
* module reads the same default, through the same lookup.
*/
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()
`,
// An abstract class the statement declares, below an exported helper.
'src/repository.ts': `export function connect(): string {
return "db"
}
export default abstract class Repository {
abstract find(id: string): unknown
describe(): string {
return connect()
}
}
`,
// React Native's AnimatedColor shape, loaded with `require(…).default`
// (read the way a default import reads it) instead of imported.
'src/animated/AnimatedColor.ts': `export function getRgbaValueAndNativeColor(value: string): string {
return value
}
export default class AnimatedColor {
constructor(value: string) {
getRgbaValueAndNativeColor(value)
}
}
`,
'src/animated/load.ts': `export function makeColors(): unknown[] {
const AnimatedColor = require("./AnimatedColor").default
const { default: Color } = require("./AnimatedColor")
return [new AnimatedColor("red"), new Color("blue")]
}
`,
// An `export default` line written in a template literal is text, not the
// module's statement (#2968). A code generator's template names the file's
// own `App`, above the real default — the issue's own example first.
'src/gen/issue.ts': `export function App() { return 1; }
export function Main() { return 2; }
export const template = \`
export default App
\`;
export default Main;
`,
// Returned by a function, behind a template nested in an interpolation.
'src/gen/render.ts': `export function App() { return 1 }
export function Main() { return 2 }
export function render() {
return \`intro \${\`nested\`}
export default App
\`
}
export default Main
`,
// Passed at module scope, where no declaration encloses it, and a class field.
'src/gen/write.ts': `import { writeFileSync } from "fs"
export function App() { return 1 }
export function Main() { return 2 }
writeFileSync("out.ts", \`
export default App
\`)
export class Emitter {
sample = \`
export default App
\`
}
export default Main
`,
// A template's anonymous default made the expression default below it
// anonymous too, which keeps every guess away.
'src/gen/model.ts': `export class Model {
save() {
return 1
}
}
export const usage = \`
export default class extends Model {}
\`
export default observable(Model)
function observable<T>(target: T): T {
return target
}
`,
// No template text: a regex and a string that hold a backtick, and a
// division after a parenthesis.
'src/gen/plain.ts': `export function App() { return 1 }
export function Main() { return 2 }
const tick = /[\`]/g
const half = (App() + Main()) / 2 + "\`"
export default Main
`,
// A regex right after `if (…)` reads as a division, so its backtick opens a
// literal the scan never closes: then the scan vouches for nothing, and the
// statement still counts.
'src/gen/unbalanced.ts': `export function App() { return 1 }
export function Main() { return 2 }
export function check(s: string, ok: boolean) {
if (ok) /\`/.test(s)
return s
}
export default Main
`,
'src/gen/use.ts': `import Issue from "./issue"
import Render from "./render"
import Write from "./write"
import Persisted from "./model"
import Plain from "./plain"
import Unbalanced from "./unbalanced"
export function generate() {
Issue()
Render()
Write()
new Persisted()
Plain()
Unbalanced()
}
`,
'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"
import Repository from "./repository"
export function consume() {
handler({}, { json() {} })
new Store().read()
headingToSlug("Vans")
stream()
Title()
Banner()
runAnonymous()
Arrow()
service.run()
}
export class Users extends Repository {
find(id: string) {
return id
}
}
`,
};
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('extends the abstract class the statement declares', () => {
const users = cg.getNodesInFile('src/use.tsx').find((n) => n.name === 'Users' && n.kind === 'class')!;
const bases = cg
.getOutgoingEdges(users.id)
.filter((e) => e.kind === 'extends')
.map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`);
expect(bases).toEqual(['src/repository.ts:Repository']);
});
it('reads `require(…).default` and `{ default: X } = require(…)` the same way', () => {
const make = cg.getNodesInFile('src/animated/load.ts').find((n) => n.name === 'makeColors')!;
const created = cg
.getOutgoingEdges(make.id)
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => `${e.kind} ${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`)
.sort();
expect(created).toEqual([
'instantiates src/animated/AnimatedColor.ts:AnimatedColor',
'instantiates src/animated/AnimatedColor.ts:AnimatedColor',
]);
});
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');
});
});
describe('an `export default` line inside a template literal', () => {
/** `file:name` of every node `generate` calls or instantiates. */
function generated(): string[] {
const generate = cg.getNodesInFile('src/gen/use.ts').find((n) => n.name === 'generate')!;
return cg
.getOutgoingEdges(generate.id)
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => cg.getNode(e.target)!)
.map((n) => `${n.filePath}:${n.name}`)
.sort();
}
it('is text, so the real statement below it names the default', () => {
const targets = generated();
for (const file of ['issue', 'render', 'write']) {
expect(targets).toContain(`src/gen/${file}.ts:Main`);
expect(targets).not.toContain(`src/gen/${file}.ts:App`);
}
});
it('does not make an expression default anonymous', () => {
expect(generated()).toContain('src/gen/model.ts:Model');
});
it('leaves regexes, strings and division alone, and a scan it cannot finish', () => {
const targets = generated();
expect(targets).toContain('src/gen/plain.ts:Main');
expect(targets).toContain('src/gen/unbalanced.ts:Main');
expect(targets).not.toContain('src/gen/plain.ts:App');
expect(targets).not.toContain('src/gen/unbalanced.ts:App');
});
});