Skip to content

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

Open
colbymchenry wants to merge 4 commits into
mainfrom
claude/hopeful-hodgkin-156996
Open

colbymchenry wants to merge 4 commits into
mainfrom
claude/hopeful-hodgkin-156996

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

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:

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 2f2afea vs main + this fix, same kernel, codegraph init -y per repo. After merging main up to ed199e6 (#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 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.

Overlap with open PRs

Not in this PR

🤖 Generated with Claude Code

colbymchenry and others added 4 commits October 7, 2026 04:38
…he first exported function

A default import resolved to the module's first exported function or
component whenever the `export default` statement wrote a declaration
(`export default function Vans()`, `export default class Store`): only a
separate `export default NAME` statement was read. A React Router 6.4+
data-router page exports its `loader` or `action` above the page
component, so `import Vans, { loader as vansLoader } from './Vans'` bound
the route `/vans` (and every call through `Vans`) to `loader`.

The resolver now reads the file's own `export default` statement, with
comments and strings masked: a declaration it writes is found by
position, a binding it names by name, and an anonymous function, class or
arrow function is no node at all (the guess took an exported function
beside it, or one nested inside it). The statement also beats an
exported styled/memo component above it. Only an expression default
(`memo(Card)`, `new Service()`), a binding the file doesn't declare, or
no statement keeps the old guess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode only

Masking strings too (blankStringContents) loses step on a template whose
`${…}` holds another template or a regex with a backtick, and blanks the
code up to the next backtick. On outline it blanked `export default
EditorContainer` at the end of a 2,900-line styled-components file and
`export default function headingToSlug` below such a regex, and the guess
that followed bound `Styles` to an unrelated exported `pulse`. Comments are
still stripped; a statement must name a node of the file, so template text
that only reads like one names nothing.

Adds the headingToSlug shape to the test, and the CHANGELOG entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 7, 2026
…ode is the module's

A template can hold statement-shaped text: react.dev's
SandpackWithHTMLOutput.tsx writes two sandbox files' `export default
function` lines above its own `export default memo(function ...)`, and
reading only the first line left the module's default import with
nothing. Every line-initial statement outside a block comment is now
read, the first that names a node wins, and a function or class a
statement declares binds only the node that starts there, so a
template's `export default function formatHTML` can't reach the file's
real formatHTML. The block-comment check reads the file once for all of
its statements.

Carries #2432's default-export tests unchanged (all pass), and adapts
its multi-statement and declaration-position rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant