Skip to content

fix(js): a JavaScript file's import of a TypeScript module links that module - #2674

Merged
colbymchenry merged 1 commit into
mainfrom
claude/js-imports-ts-modules
Oct 19, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/js-imports-ts-modules

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

What was wrong

An extensionless import from a .js or .jsx file of a TypeScript module never resolved by path. mastodon's modal_root.jsx does import AudioModal from './audio_modal', and the module is audio_modal.tsx beside it. The cause was EXTENSION_RESOLUTION in src/resolution/import-resolver.ts: the javascript and jsx lists held only JavaScript extensions, while typescript and tsx already try .js / .jsx.

In a codebase moving to TypeScript, every such import fell through to name matching:

  • An import under another name (import Modal from './audio_modal') or a repeated name linked nothing.
  • A name that repeats linked the wrong file. mastodon's <IconButton> (×16) rendered components/button/redesign.tsx's IconButton, not the icon_button.tsx each file imports. Its <Status> (×10) rendered the legacy class, not components/status/index.tsx, and happa's Button imports (×23) landed on a stories file's Button.
  • A .jsx file's JSX tags couldn't follow their imports (fix(react): a JSX tag imported from the project renders what its module exports #2563's import-first rendering), because the imports never resolved.

Found while triaging #2673: its probe's "no-module" bucket was these imports. No open issue or PR covers it; #1484 and #1767 handle a .js specifier inside TypeScript files.

The fix

The javascript and jsx lists now try .ts / .tsx (and /index.ts / /index.tsx) after their own extensions, as a bundler and tsc's allowJs resolve them:

  • JavaScript stays first, so a helpers.ts beside helpers.js doesn't take its importers.
  • Never .d.ts: a declaration file is not a module a JavaScript file runs, and declared packages stay closed to JavaScript importers as before.

Tests

__tests__/js-imports-ts-modules.test.ts:

  • A .jsx file default-imports a .tsx component under another name, imports a function whose name also exists in a second module, and its JSX renders the import.
  • A .js file imports a folder's index.ts.
  • Control: a .js importer of ./helpers keeps helpers.js when helpers.ts also exists.

Red on main ab6ec21: the first test gets no edges at all from the .jsx file and only name-matched ones from the .js file. Green with the fix. The control passes on both.

Full suite on this branch at main ab6ec21 (Windows 11, kernel built from the tree, CODEGRAPH_KERNEL_EXPECT=1, --maxWorkers=4 --minWorkers=1): 7,491 passed, 80 skipped, 1 failed. The failure was mcp-daemon.test.ts's "does not replace a live legacy lock with a second daemon": a daemon candidate still running at the check under parallel load. It's a process-lifecycle test that import resolution never touches, and the file passes 21/21 alone, twice.

A/B on real repos

The same 23-repo kit as #2671 and #2673. Both arms are this branch on main ab6ec21 with the same kernel, identical except dist/resolution/import-resolver.js. Edges are paired per site (source node, position, edge kind, reference name). Synthesized edges are diffed whole. Node counts are identical everywhere.

repo same target, now via the import retargeted added removed jsx-render
mastodon 1,116 55 733 1 +64 / −46
happa 340 162 671 0 +14 / −3
react.dev 13 4 17 0 +1 / −5
home-assistant frontend 0 0 2 0 0
bitwarden 0 0 1 0 0
18 others 0 0 0 0 0 (byte-identical)

Triage, checked against the fixed arm's own resolver:

  • Added (1,424 sites): every new target is in the module the import names (1,395), or is the binding the import resolver follows through a re-export (16). The other 10 are module-path imports of .ts / .tsx files (e.g. home-assistant's gallery/src/entrypoint.js → ha-gallery.ts, bitwarden's .storybook/manager.js → panel.tsx).
  • Retargeted (221): every new target is in the module the import names. Two kinds:
    • A default import (import RoutePath from 'utils/routePath', mastodon's api ×27) now links the module file, exactly as the same import from a .ts / .tsx file already did on main. In happa, the 26 TypeScript importers of utils/routePath link the file; the 21 JavaScript ones linked the class by name.
    • Corrections to the file the import names: happa's Button ×23 (a stories file before); mastodon's IconButton ×15, Toggle, Poll and Card (stories before), addPollOption (compose_typed.ts, not compose.js), { ImmutableHashtag as Hashtag }, and Video (the component, not its async loader); react.dev's CodeBlock folder index.
  • Removed (1): mastodon's IconButton function value → button/redesign.tsx (wrong). The import now binds icon_button.tsx's class, which no function-value edge names (fix(js,ts): a function value imported from the project links only to what its import binds #2673).
  • jsx-render: tags in .jsx files now render the component their file imports. In mastodon, IconButton ×16, Status ×10, Dropdown, Toggle, ColumnHeader, Video, NavigationBar, Footer, RadioButton and the autosuggest items all move from a namesake to the import, and new renders appear where the import was the only way to the component (ColumnHeader ×15, BoostButton, SkipLinks; happa's DateComponent, FlashMessage). Three of the "lost" renders are same-file moves (ShortNumber → the ShortNumberRenderer it wraps, Hashtag → the aliased ImmutableHashtag). react.dev's 4 lost <CodeBlock> renders in HomeContent.js now match the .tsx importers of that module on main, which render nothing there either: its default export is memo(function CodeBlockWrapper…), which fix(react): a JSX tag renders what the file imports under that name #2451's default-export reading would link for both.

Cost: none measurable. Index times match within noise (mastodon 21 s / 20 s, happa 8 s / 9 s, bitwarden 57 s / 58 s). Only JavaScript imports that miss every JavaScript candidate probe further.

🤖 Generated with Claude Code

… module

An extensionless import from a `.js` or `.jsx` file of a TypeScript module,
like mastodon's `import AudioModal from './audio_modal'` in modal_root.jsx
beside audio_modal.tsx, never resolved by path: the import resolver's
javascript and jsx extension lists held only JavaScript extensions, while
the typescript and tsx lists already try `.js`/`.jsx`. In a codebase
moving to TypeScript those imports were matched by name instead, so an
import under another name linked nothing, and a repeated name could link
the wrong file (mastodon's `<IconButton>` and `<Status>` tags rendered
other same-named components than the ones each file imports).

The javascript and jsx lists now try `.ts`/`.tsx` (and their index files)
after their own extensions, as a bundler and tsc's allowJs resolve them.
JavaScript stays first, so a `helpers.ts` beside `helpers.js` does not
take its importers, and never `.d.ts`, which is no module a JavaScript
file runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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