Skip to content

fix(tsx): treat a JSX element as a component call, so callers and blast see the component layer (#382) - #389

Open
johnatbasicas wants to merge 2 commits into
trailhq:mainfrom
johnatbasicas:fix/tsx-jsx-element-call-edges
Open

johnatbasicas wants to merge 2 commits into
trailhq:mainfrom
johnatbasicas:fix/tsx-jsx-element-call-edges

Conversation

@johnatbasicas

@johnatbasicas johnatbasicas commented Sep 14, 2026

Copy link
Copy Markdown

Closes #382.

What

A symbol used as a JSX element produced no graph edge — only call expressions did. So callers reported no indexed callers for components rendered across the whole app, and blast reported an empty impact set for a diff touching them. Since React components are essentially always used as JSX, that left callers/blast structurally blind across the component layer — the one place a "you changed this, these break" answer is worth most.

<Widget/> is how a component gets invoked: the runtime calls the function and passes it props. It just parses as jsx_opening_element / jsx_self_closing_element rather than call_expression. This is the same argument the table's own comment already makes for Java's object_creation_expression.

Three things worth calling out:

  • jsx_closing_element is deliberately absent. </Widget> is the tail of the usage the opening tag already recorded; counting it would double every JSX edge. A test pins exactly one edge per paired element.
  • The intrinsic test is JSX's own rule, not an A-Z check. An earlier draft used /^[A-Z]/, which is ASCII-only and silently dropped <Écran/>, <_Widget/> and <$Widget/> — all valid bindings. The check now mirrors the TypeScript compiler's isIntrinsicJsxName: lowercase first character, or a hyphen anywhere. The hyphen half is not theoretical — my-element arrives as an identifier from the grammar. A namespaced name (<svg:circle/>) is a jsx_namespace_name, a different node type, so it is already excluded, which matches TypeScript treating namespaced names as intrinsic.
  • kinds: ["function", "class"] is set on the edge, not widened into the tsx default, so an ordinary Widget() call in the same file still resolves against functions alone. A class component is as much a component as a function one.

A dotted element name (<UI.Button/>) is left alone on purpose: it is a member_expression needing a receiver type the way ui.button() does, and the namespace import it usually comes from binds none. That is the same wall qualified construction hits in Java; resolving the trailing segment alone is the guess this module does not make.

JSX_ELEMENT_TYPES is defined once and spread into CALL_TYPES.tsx, so the table and calleeName cannot drift apart. Only the tsx grammar can reach these node types — .ts/.js are parsed by typescript, which has no JSX — so widening that entry would be dead weight.

Measured

On a production Next.js + Kotlin monorepo (1,340 indexed files, 272 .tsx, 13,589 symbols):

A real merged PR whose fix touched one source file — a context provider — and required updating five test files that mount it:

before:  impacted: 0 symbols in 0 areas
         ✗ Provider — 1 changed file, 0/1 reached by a test

after:   impacted: 1 symbol in 1 area
         ⚠ Provider — 1 changed file, 1/1 reached by a test
         4 test suites also reference this code

callers on that component went from no indexed callers to five consumers — the app's root layout plus four of the five test files, 0 false positives. The fifth mounts it through a namespace import, the dotted case above. 31,216 edges (+374 over baseline).

Ranking and hotspots are unaffected by table membership: CALL_TYPES has no consumer outside extract.ts, and computeInDegree walks resolved edges, so <div/> — where calleeName returns null — adds no score.

Tests

4 tests in test/graph-jsx.test.ts, each verified red before the change and green after:

  • mounting a component makes the consumer a caller, both spellings and both extensions
  • a class component is a component
  • a host element and a dotted element name are not symbols
  • the intrinsic test is JSX's own rule, not an A-Z check (Écran, _Widget, $Widget, div, my-element, svg:circle)

Full suite: 1225/1225, exit 0. tsc -p tsconfig.json --noEmit: exit 0.

(The suite needs LC_ALL=en_US.UTF-8; without it nine pre-existing thousands-separator assertions fail on main too — unrelated to this change.)

Platform safety

CI gates on windows-latest as well as ubuntu-latest, so it is worth saying where this change can and cannot be platform-sensitive.

The intrinsic test runs on an identifier's text, taken from the tree-sitter node via childForFieldName("name") — never on a path. /^[a-z]/ and name.text.includes("-") therefore see neither path separators nor line endings; \ versus / and CRLF versus LF cannot change the outcome. The character-class reasoning is deliberately ASCII on the lowercase side because that is exactly what TypeScript's own isIntrinsicJsxName does (charCodeAt(0) between 97 and 122); the earlier /^[A-Z]/ draft was ASCII on the uppercase side, which is what silently dropped <Écran/> — that asymmetry is the whole point of the fix and it is platform-independent in both directions.

JSX_ELEMENT_TYPES is a set of tree-sitter node-type strings, so it carries no platform surface either. The change adds no filesystem access, no shell invocation and no path construction.

…railhq#382)

`<Widget/>` is how a React component is invoked — the runtime calls the function
and hands it the props — but it parses as `jsx_opening_element` /
`jsx_self_closing_element` rather than `call_expression`, so mounting a component
produced no edge while `Widget({children})` produced one. Components are
essentially always mounted, so `callers` and `blast` were structurally blind
across the entire component layer: a real merged PR that changed one provider and
had to update the five test files mounting it reported a blast radius of 0
symbols in 0 areas, with every one of those files imported and indexed.

This is the argument that already made this table a set — Java's constructor
calls are `object_creation_expression`, one more shape of the same fact.

Only capitalized element names: JSX's own rule is that `<div>` is an intrinsic
host element React forwards to the DOM as a string, not a binding. A dotted name
(`<UI.Button/>`) needs a receiver type its namespace import does not give, so it
drops rather than guessing at the trailing segment. Element names widen to class
components; an ordinary call in the same file still resolves against functions
alone.
…not dropped

`/^[A-Z]/` is not the complement of "is this a host element". JSX's rule, as
TypeScript spells it in isIntrinsicJsxName, is `ch >= 'a' && ch <= 'z' ||
name.includes("-")` — so `<Écran/>`, `<_Widget/>` and `<$Widget/>` are all
ordinary bindings, and all three were silently dropped. On a non-English codebase
that is most of the component layer.

Testing the intrinsic side instead also picks up hyphenated custom elements
(`<my-element/>`), which the A-Z test happened to exclude for the wrong reason.
The ASCII range is right here precisely because it is the lowercase half.
@trailhq-graft

trailhq-graft Bot commented Sep 14, 2026

Copy link
Copy Markdown

🌱 graft blast radius

Nothing outside this diff depends on it. 1 area changed; no indexed dependents at depth 2.
Tests: no test reaches Call Graph Extraction.
Tag: @Frankie-Xu — Call Graph Extraction · @anirudhkumar-nanonets — Call Graph Extraction

Who knows this code — 2 people across 1 area
Area Who knows it
Call Graph Extraction · changed @Frankie-Xu — 5 commits, last 18d ago · @anirudhkumar-nanonets — 5 commits, last 1mo ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

Test signal per changed area — 1 ✗

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • Call Graph Extraction — 0 of 1 reached · no test file reaches it
    • not reached: calleeName

graft blast · origin/main...HEAD · depth 2 · 2 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 14, 2026
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.

TS/TSX: JSX element usage produces no edge, so callers and blast report nothing for React components

2 participants