Skip to content

JS/TS: a .js import specifier never resolves to a .tsx file, dropping every NodeNext-style import of a TSX module #3486

Description

@cbartens

Summary

_resolve_js_import_path maps a .js import specifier to .ts and stops. TypeScript's documented resolution order for a .js specifier is .ts.tsx.d.ts.js, so every NodeNext/Node16-style import of a .tsx file is dropped from the graph.

On a 1,430-file TypeScript monorepo this loses 171 import edges, and presents as .tsx files appearing unreachable.

Where

graphify/extractors/resolution.py (branch v8, 0.9.58 — also 0.9.50):

# TS ESM convention: imports often spell .js/.jsx while source is .ts/.tsx.
if candidate.suffix == ".js":
    ts_candidate = candidate.with_suffix(".ts")
    if ts_candidate.is_file():
        return ts_candidate
elif candidate.suffix == ".jsx":
    tsx_candidate = candidate.with_suffix(".tsx")
    if tsx_candidate.is_file():
        return tsx_candidate

The comment names the right convention, but .js only ever tries .ts. A .tsx source file is imported as .js, not .jsx — TypeScript emits Foo.tsx to Foo.js, so the specifier the compiler requires is ./Foo.js. ./Foo.jsx is only correct under jsx: preserve, which is the rarer case.

The append-extension loop underneath does not rescue it, because it appends to the full filename rather than replacing the suffix:

for ext in _JS_RESOLVE_EXTS:
    with_ext = candidate.parent / f"{candidate.name}{ext}"   # MessageBubble.js.tsx

Reproduction

src/Button.tsx          export function Button() {}
src/App.ts              import { Button } from "./Button.js";

with "moduleResolution": "nodenext". Expected: an import edge App.ts → Button.tsx. Actual: none — Button.tsx has zero inbound edges.

Directly against the resolver:

>>> from graphify.extractors.resolution import _resolve_js_import_path as R
>>> from pathlib import Path
>>> R(Path("src/Button.js"))       # Button.tsx exists on disk
PosixPath('src/Button.js')          # unresolved
>>> R(Path("src/helper.js"))       # helper.ts exists on disk
PosixPath('src/helper.ts')          # fine

Measured impact

Real monorepo, 1,430 source files, 10,958 nodes / 19,266 edges:

Relative .js specifiers Count
resolve to .ts 1,041
resolve to .tsxedge lost 171

The asymmetry is visible in the graph as a large difference in apparent reachability between two extensions under one import convention:

Non-test source Files Zero inbound Rate
.ts 472 47 10%
.tsx 185 82 44%

After repairing only this one case out-of-band, .tsx drops to 15% and 54 files stop reading as unreachable.

This matters beyond edge counts: it makes affected, any dead-code pass, and any reachability question quietly wrong for .tsx files, in the direction that is hardest to notice — a missing edge looks like a clean result.

Suggested fix

Replace the branch with a suffix→candidates table, preserving the existing order semantics:

_TS_OUTPUT_EQUIVALENT = {
    ".js":  (".ts", ".tsx", ".d.ts"),
    ".jsx": (".tsx",),
    ".mjs": (".mts", ".d.mts"),
    ".cjs": (".cts", ".d.cts"),
}

for replacement in _TS_OUTPUT_EQUIVALENT.get(candidate.suffix, ()):
    sibling = candidate.with_suffix("")  # .with_suffix(".d.ts") mangles the stem
    rewritten = sibling.parent / f"{sibling.name}{replacement}"
    if rewritten.is_file():
        return rewritten

Note Path.with_suffix(".d.ts") does not do what it looks like it does on a multi-dot stem, hence building the name by concatenation.

.mjs/.cjs are included for completeness; I have no repro for those, only for .js.tsx.

Happy to open a PR with this plus a test if that is useful.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions