fix(js,ts): a default import is what `export default` declares, not the first exported function

Merged
CO
8d ago
+405-42

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:

  • defaultExportBindingNode only read a separate export default NAME statement, and
  • defaultFnClass is 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 exported component node) beat the statement, so a .tsx file exporting a styled/memo component 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's isExported walks 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 named NAME that 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, and export default\nfunction X works.
  • 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 old defaultComponent ?? defaultFnClass guess. 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 (an export 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* stream written across a line break, and headingToSlug below a regex-with-backtick template (fails if strings are masked).
  • the declared default and an export default Banner binding beat an exported styled component.
  • anonymous function/arrow defaults link to nothing, not to helper/inner/useThing/handle.
  • control: export default new Service() still reaches Service.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.

reposhapeedges re-resolvedremovedaddedrefs now resolving / now unresolved
BlazejBatko/react-router-v6-learning-project (vanlife, the repro)data router8000 / 0
remix-run/react-router examples/ @6.29.0data router (notes)3000 / 0
epicweb-dev/epic-stackRemix / RR7 framework1000 / 0
hyperdxio/hyperdxNext.js pages router73300 / 3
outline/outlineReact + Koa230133523 / 13
react-boilerplate/react-boilerplateReact + redux-saga18800 / 8
folo (RSSNext/Folo)Electron + Expo0100 / 1
binner (replaysMike/Binner)React Router v68 (same targets)000 / 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-turbobyte-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 as instantiates of the class declared inside the factory. They are now unresolved.
  • hyperdx (3): rateLimiter(...) (export default (config?: Partial<Options>) => rateLimit(...)) went to rateLimiterKeyGenerator. Now unresolved.
  • folo (1): the test's createExpoConfig(...) (export default ({ config }: ConfigContext): ExpoConfig => …) went to resolveRuntimeVersion, 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] plus DeliverWebhookTask. 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 (bareClassOk is Python-only), so they are unresolved. The other 2: calls to markRule, export default function (options) {…}, went to emphasisPlugin.

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 to export default class AnimatedValue/AnimatedColor used as values, which the guess had sent to flushValue/getRgbaValueAndNativeColor.
  • topcoder: 3 re-resolved to the declared default. 2 removed: LivePreview (export default class ThriveArticleLivePreview at L152) sent to isThriveLivePreview, and createCustomPlugin(...), an anonymous export default ({ editor }) => {…}, sent to updateEntityData, 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 findExportedSymbolWalk with 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 default statement, 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.tsx has sandbox template text at L16 (export default function ShowRenderedHTML) above the real export 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 default statement 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.

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's defaultExportName does, would be the next step; #2451 does it.
  • import X from './X'; export default X in 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 NAME reader rather than adding another reader.

🤖 Generated with Claude Code