fix(tsx): treat a JSX element as a component call, so callers and blast see the component layer (#382) - #389
johnatbasicas wants to merge 2 commits into
Conversation
…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.
🌱 graft blast radiusNothing outside this diff depends on it. 1 area changed; no indexed dependents at depth 2. Who knows this code — 2 people across 1 area
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 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.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
Closes #382.
What
A symbol used as a JSX element produced no graph edge — only call expressions did. So
callersreportedno indexed callersfor components rendered across the whole app, andblastreported an empty impact set for a diff touching them. Since React components are essentially always used as JSX, that leftcallers/blaststructurally 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 asjsx_opening_element/jsx_self_closing_elementrather thancall_expression. This is the same argument the table's own comment already makes for Java'sobject_creation_expression.Three things worth calling out:
jsx_closing_elementis 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.A-Zcheck. 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'sisIntrinsicJsxName: lowercase first character, or a hyphen anywhere. The hyphen half is not theoretical —my-elementarrives as anidentifierfrom the grammar. A namespaced name (<svg:circle/>) is ajsx_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 thetsxdefault, so an ordinaryWidget()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 amember_expressionneeding a receiver type the wayui.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_TYPESis defined once and spread intoCALL_TYPES.tsx, so the table andcalleeNamecannot drift apart. Only thetsxgrammar can reach these node types —.ts/.jsare parsed bytypescript, 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:
callerson that component went fromno indexed callersto 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_TYPEShas no consumer outsideextract.ts, andcomputeInDegreewalks resolved edges, so<div/>— wherecalleeNamereturnsnull— adds no score.Tests
4 tests in
test/graph-jsx.test.ts, each verified red before the change and green after:A-Zcheck (É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 onmaintoo — unrelated to this change.)Platform safety
CI gates on
windows-latestas well asubuntu-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]/andname.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 ownisIntrinsicJsxNamedoes (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_TYPESis 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.