fix(js,ts): a default import is what `export default` declares, not the first exported function
What was wrong
A JS/TS default import resolved to the wrong symbol whenever the module exported a function above its export default function X() / export default class X. findExportedSymbolWalk's default branch took defaultComponent ?? defaultExportBindingNode(...) ?? defaultFnClass:
defaultExportBindingNodeonly read a separateexport default NAMEstatement, anddefaultFnClassis the file's first exported function or class (ORDER BY start_line).
A React Router 6.4+ data-router page exports its loader/action above the page component, so in the reported repro (BlazejBatko/react-router-v6-learning-project) import Vans, { loader as vansLoader } from "./pages/Vans/Vans" bound Vans to loader. On main /vans, /vans/:id, /host, /host/vans and the three HostVanDetail layout edges all pointed at loader, and /login at action.
Two related guesses failed the same way:
defaultComponent(the first exportedcomponentnode) beat the statement, so a.tsxfile exporting astyled/memocomponent lost its default function or binding to that component.- An anonymous default (
export default function () {…},export default class {…},export default () => …) took whatever exported function came first, including one nested inside the default itself, because the extractor'sisExportedwalks the whole parent chain.
The fix
statedDefaultExport (in src/resolution/import-resolver.ts) reads the file's own export default statement and replaces the old binding-only reader:
- Declaration (
export default [async] function[*] NAME,export default [abstract] class NAME): the node namedNAMEthat starts between the statement's line and the name's line. That is found by position, so a nested same-name function can't stand in, andexport default\nfunction Xworks. - Binding (
export default NAME): unchanged semantics. The first function/class/component/constant/variable of that name. - Anonymous function, class or arrow function: no node. The default import resolves to nothing rather than to a neighbour.
- Anything else (
memo(Card),connect(...)(Card),new Service(),{ … }, a binding the file doesn't declare, no statement):'unstated', which keeps the olddefaultComponent ?? defaultFnClassguess. Svelte/Vue SFCs never reach the reader, so their component still wins (#629).
Comments are stripped before scanning, but strings are deliberately not masked. blankStringContents gets out of step on a template whose ${…} holds another template or a regex containing a backtick. In validation it blanked the real statement in 6 files across outline/excalidraw/folo/dokploy: outline's headingToSlug.ts and its 2,900-line styled-components Styles.ts, where the fallback guess then produced a wrong Styles → pulse edge. A statement now has to name a node of the file to count, so template text that merely says export default function X names nothing. Of several statements, one that names a node wins.
Kernel
There is no parallel path to keep in step. codegraph-kernel only extracts; import resolution is TS-only. The change reads the nodes both extractors emit, so I checked that they emit the same ones for every shape involved: 31 default-export shapes (named/async/generator/class/abstract/decorated/anonymous/arrow/multiline/overloads/in-comment/in-template/styled-first, …) are 31/31 byte-parity under scripts/kernel-parity.mjs. The new test passes with the kernel (CODEGRAPH_KERNEL_EXPECT=1) and on wasm (CODEGRAPH_KERNEL=0).
Tests
__tests__/default-export-declaration.test.ts (new), red on main (4 of 5 failing) → green:
- vanlife-shaped data router:
/vans,/host/vans(anexport const loader = async () =>arrow),/login(export async function action) bind to the page components. - calls through default imports reach
export default async function handler(Next.js API route),export default class Store,export default function* streamwritten across a line break, andheadingToSlugbelow a regex-with-backtick template (fails if strings are masked). - the declared default and an
export default Bannerbinding beat an exportedstyledcomponent. - anonymous function/arrow defaults link to nothing, not to
helper/inner/useThing/handle. - control:
export default new Service()still reachesService.run(passes on main too).
On the branch merged with main, the default-export and resolution suites pass with the kernel staged: namespace-object-resolution, esm-unexported-binding, store-exported-later, react-router, react-router-nested-lazy, expo-router, nextjs, extraction, resolution and kernel-tsjs-parity, 1,238 tests. tsc --noEmit is clean.
The full parallel suite ran on a saturated machine (100% CPU, 43 node processes from other sessions; 25 minutes). It reported 734 failures in 181 files. 722 were timeouts, 6 were EBUSY/EPERM teardown errors, and 6 were timing assertions in daemon, watchdog, writer-lock and MCP-lifecycle tests (e.g. expected 100.36 to be less than 100 ms). A serial rerun of those 181 files (--no-file-parallelism --testTimeout=120000, 94 minutes at the same load) left 7 failures, all timing or teardown: function-ref's #1820 case (its own 60 s limit), orphaned-refs-sweep (its own 15 s limit), mcp-subproject-adoption (a 5 s handshake timer), mcp-writer-lock (a liveness check read unverified), mpeg-ts-not-typescript (2359 < 2000 ms), ui-server-api (156.8 < 100 ms) and daemon-pid-reuse (EBUSY removing its temp dir). Run alone, all 7 pass; the two in-file limits were raised for that run only.
Validation (before/after scripts/dump-graph.mjs diffs)
Arms: main 2f2afea8 vs main + this fix, same kernel, codegraph init -y per repo. After merging main up to ed199e60 (#2413 and #2417, C++/Go only), vanlife, epic-stack and hyperdx re-index byte-identical to the pre-merge after arm.
| repo | shape | edges re-resolved | removed | added | refs now resolving / now unresolved |
|---|---|---|---|---|---|
| BlazejBatko/react-router-v6-learning-project (vanlife, the repro) | data router | 8 | 0 | 0 | 0 / 0 |
remix-run/react-router examples/ @6.29.0 | data router (notes) | 3 | 0 | 0 | 0 / 0 |
| epicweb-dev/epic-stack | Remix / RR7 framework | 1 | 0 | 0 | 0 / 0 |
| hyperdxio/hyperdx | Next.js pages router | 73 | 3 | 0 | 0 / 3 |
| outline/outline | React + Koa | 230 | 13 | 35 | 23 / 13 |
| react-boilerplate/react-boilerplate | React + redux-saga | 18 | 8 | 0 | 0 / 8 |
| folo (RSSNext/Folo) | Electron + Expo | 0 | 1 | 0 | 0 / 1 |
| binner (replaysMike/Binner) | React Router v6 | 8 (same targets) | 0 | 0 | 0 / 0 |
remix-run/indie-stack, reactjs/react.dev (Next.js pages), Dokploy/dokploy, excalidraw, berry, redux-realworld, refine examples/ | identical | ||||
| controls: bradtraversy/proshop_mern, leerob/next-saas-starter, t3-oss/create-t3-turbo | byte-identical |
Every one of the 341 re-resolutions was checked against an independent oracle (a raw-source regex for the target file's export default name, no resolver code): 316 land on the file's declared default, 2 on members of a default class (outline ToggleBlock.isEmpty / isHeadEmpty, previously toggleStorageKey), and 23 keep the same target with new metadata. Of those 23, 15 are upgrades: outline/hyperdx function refs that had fallen back to name matching at 0.8, and 4 client.userInfo() calls in outline's OAuth test at 0.7, now resolve through the import at 0.9, because a styled/memo component or a wrong guess no longer intercepts the default. The other 8 are binner downgrades (below). None were unexplained.
Highlights: vanlife's 5 routes + 3 layouts and rr-examples' notes route + 2 layouts reach the page (Vans, Login, VanDetail, Dashboard, HostVans, HostVanDetail, Root) instead of loader/action. epic-stack's test import { default as UsernameRoute, loader } (a default import since #2412) reaches ProfileRoute. hyperdx's useSidePanelStack ×30, useRowWhere ×21, CheckAlertTask ×9 and ProvisionDashboardsTask ×7, and outline's Relation ×56, pagination ×45, lazyWithRetry ×36 and documentCreator ×18, previously all went to the file's first exported helper. On main, outline's POST documents.create route had no edge to documentCreator: its call was guessed as authorizeDocumentCreate and merged into the route's own direct call to that function.
Every removed edge (25), each a wrong edge the guess made:
- react-boilerplate (8):
injectReducer(...)/injectSaga(...)(×2 copies, app + internals template) are anonymous HOC factories,export default ({ key, reducer }) => WrappedComponent => { class ReducerInjector … }. The calls were recorded asinstantiatesof the class declared inside the factory. They are now unresolved. - hyperdx (3):
rateLimiter(...)(export default (config?: Partial<Options>) => rateLimit(...)) went torateLimiterKeyGenerator. Now unresolved. - folo (1): the test's
createExpoConfig(...)(export default ({ config }: ConfigContext): ExpoConfig => …) went toresolveRuntimeVersion, which the same test imports separately by name. Now unresolved. - outline (13): 11 function refs to classes used as values, editor extension lists like
[CodeFence, Image, ToggleBlock]plusDeliverWebhookTask. They went to an unrelated exported helper (expandCodeBlockAt,downloadImageNode,toggleStorageKey,isExpectedNetworkError). The import now resolves to the class, which a TS function ref deliberately doesn't target (bareClassOkis Python-only), so they are unresolved. The other 2: calls tomarkRule,export default function (options) {…}, went toemphasisPlugin.
Added edges (35, all outline): 7 extends that failed on main (OAuthClient ×5 including the test's subclass, CodeFence, MarkdownAPIImportTask; the guess returned a function, which extends rejects), the 11 interface-impl override calls those extends edges enable, 16 function refs to Button/ListItem that resolve through the import now, and documents.create → documentCreator.
binner: export default (props) => <Search {...props} params={useParams()} … /> is an anonymous wrapper around the file's own export function Search. Main's first-exported guess happened to land on Search. Now the route is bound by name to the same node (exact-match 0.5 instead of import 0.9). A survey of ~35 local React repos found this forwarding-wrapper shape only in binner's 6 page files, so I left the anonymous rule as specified rather than add a look-through here.
Merged with #2433, re-validated on main cd97bb37
#2433 (require('./x').default) landed first and built its fallback on the old guess chain (esmDefaultExport → defaultExportBindingNode → first exported function). The merge keeps #2433's gates (hasEsmDefaultExport, depth 0, JS family) and has esmDefaultExport read the export default statement first, so a require().default reaches the declared default too. The binding-only reader is gone. require-default, import-default-as and this PR's tests pass on both the kernel and wasm paths. The JS/TS suites that landed since (catch-all alias, import-binding sync, lazy barrels, JSX disambiguation, HOC, kernel parity and others) pass 569/569 with the kernel rebuilt.
The A/B was re-run on main cd97bb37 against main + this PR, adding #2433's react-native and topcoder (that session's survey found this PR's shape in 7 react-native and 4 topcoder files):
- The 18 repos above give byte-identical change sets to the first run.
- react-native: 137 re-resolved (111 to the declared default, 18 to members of a default class, 8 same-target upgrades from name matching at 0.7/0.8 to the import at 0.9, all
createAnimatedComponent). 4 removed: function refs toexport default class AnimatedValue/AnimatedColorused as values, which the guess had sent toflushValue/getRgbaValueAndNativeColor. - topcoder: 3 re-resolved to the declared default. 2 removed:
LivePreview(export default class ThriveArticleLivePreviewat L152) sent toisThriveLivePreview, andcreateCustomPlugin(...), an anonymousexport default ({ editor }) => {…}, sent toupdateEntityData, a function nested inside it.
Overlap with open PRs
- #2451 (a JSX tag renders what the file imports under that name) rewrites the same default branch of
findExportedSymbolWalkwith a broader reading, so the two conflict and should converge on one.- #2451 also reads wrapper calls (
memo(Card),connect(m)(Bar),observer(function X…)),new X()as the class,export { X as default }, and an imported binding re-exported as the default. Once a JS file has a statement it never guesses. That covers both follow-ups under "Not in this PR". - This PR scans every
export defaultstatement, keeps the first one that names a node, and finds declarations by position. #2451 reads only the first statement, and on reactjs/react.dev that loses a default.src/components/MDX/SandpackWithHTMLOutput.tsxhas sandbox template text at L16 (export default function ShowRenderedHTML) above the realexport default memo(function SandpackWithHTMLOutput(…))at L82. On #2451's build,MDXComponents.tsx's 2 function refs to it drop from import 0.9 to name-match 0.8. - All 5 of this PR's test cases pass on #2451's head (dce9ba4a), with the test file copied over unchanged.
- Both sessions agree on the combined reading: #2451's reading plus this PR's multi-statement preference and position check, carried by whichever PR lands second. If #2451 lands first, this PR shrinks to that preference and its extra test cases. Merge order is the maintainer's call.
- Update: #2451 at d1553d63 now carries this PR's rules: every
export defaultstatement is read, the first that names a node wins, and a declared function or class is found by position. It also includes this PR's test file unchanged. - Cross-check: d1553d63 was built and indexed on every repo where this PR changes anything. Each reference site this PR changes relative to main has the same target and the same
resolvedBy/confidence on #2451: vanlife 8, rr-examples 3, epic-stack 1, react-boilerplate 26, binner 8, folo 1, hyperdx 76, outline 275. That is 398 of 398 sites, with no differences, and the three controls are byte-identical on both PRs. - The react.dev case above resolves through the import again on #2451. This PR is superseded if #2451 lands; which one lands, and closing the other, is the maintainer's call.
- #2451 also reads wrapper calls (
Not in this PR
- Expression defaults (
export default memo(Card),connect(mapState)(Card),withRouter(Card),new Service()) keep the first-exported guess, which is right only when the wrapped binding is the first exported function. Looking through a wrapper to its argument, as expo-router'sdefaultExportNamedoes, would be the next step; #2451 does it. import X from './X'; export default Xin a barrel: the binding names an import, not a local declaration, so it still falls to the guess. Following the import like a re-export would close it; #2451 does that too.- #1721 proposes computing export status at extraction time. This PR stays resolver-side by replacing the resolver's existing
export default NAMEreader rather than adding another reader.
🤖 Generated with Claude Code