Skip to content

fix(react): a name a component destructures, or a function around it binds, renders no JSX child - #2676

Merged
colbymchenry merged 3 commits into
mainfrom
claude/dreamy-khorana-a34e85
Oct 19, 2026
Merged

colbymchenry merged 3 commits into
mainfrom
claude/dreamy-khorana-a34e85

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Decision: a destructured JSX name links nothing

#2442 made a tag the component binds itself, a const or a parameter, render only a component the parent declares inside itself. It left destructuring out on purpose, matching name-matcher's rule for calls: const { t } = useI18n() is usually that very function. A follow-up asked whether JSX should follow the call rule or the local rule. The data on current main says JSX should follow the local rule.

A TypeScript-checker survey of every jsx-render edge on 31 repos (main a50104c) found 24 edges where every tag of the name is a destructured local. 15 of them link a wrong component, and 9 link the right one by luck:

Repo Binding Edges Right Wrong
excalidraw examples/with-script-in-browser const { Sidebar, Footer, WelcomeScreen, TTDDialog, … } = excalidrawLib 14 9 5
refine-examples props: ({ children, Header, Footer, Sider, OffLayoutArea }) =>, a store Footer's ({ Icon }) 7 0 7
refine-examples const { Title } = Typography (antd), const { Link } = useRouterContext() 3 0 3
  • The wrong excalidraw targets: Sidebar reaches the example's own sidebar/ExampleSidebar.tsx, not the library's Sidebar. Footer reaches components/footer/Footer.tsx, but the library exports footer/FooterCenter as Footer.
  • The 9 "right" excalidraw targets (TTDDialog, TTDDialogTrigger, WelcomeScreen, LiveCollaborationTrigger, Button) are right only because the example app lives inside the library's own repository. In an app that consumes a library, the same destructuring can only reach a stranger.
  • Every refine target is another example's component.

What a component destructures is a component its props or a library hand it, and that component is no node in the graph. So silence beats a guess here, which is the reverse of the call case. Calls are unchanged: jsCodeBindsName and every name-matcher path are untouched.

6 of the 14 excalidraw edges come from renderExcalidraw and renderTopRightUI, functions nested inside ExampleApp that render what ExampleApp destructures. A check that reads only the parent's own lines would treat the same binding two ways. So the binding check reads the functions around the parent too, for #2442's const/parameter rule as well as for destructuring.

What changes

In reactJsxChildEdges (callback-synthesizer.ts):

  • destructures(src, name, end) reads the parent's code-only view (jsxCode, where string contents are blank) through the tag's first line. It looks for a destructuring pattern that binds the name:
    • a declaration's pattern (const/let/var, including for (const { X } of …));
    • a parameter's pattern, where an arrow or a body follows the list and no if/while/for/switch/with precedes it;
    • nested patterns, rest elements, rename targets ({ icon: Icon }) and array patterns (const [Panel] = useState()).
  • What binds nothing:
    • a rename's key ({ Footer: Custom });
    • a default value ({ Header = DefaultHeader });
    • a type literal after a pattern (}: { footer: Footer }, props: { footer: Footer });
    • an object argument (render({ Footer }));
    • a destructured require(…) or await import(…), which binds the module's own export and links as before.
  • Functions around the parent. If the parent doesn't bind the name, the functions around it are checked, innermost first, with the same const/parameter/destructuring check. The function that binds the name is the one passed to declaredInside. So binner's recursiveTreeItem still renders the StyledTreeItem its enclosing component declares. Those 9 edges, one per plain local bound in an enclosing function, are unchanged in the A/B.
  • The span scan moved into spanTags, so an enclosing function's span is read on demand and cached like a parent's.

Validation

Fresh before/after indexes on the 31 survey repos, compared with scripts/dump-graph.mjs. Every changed jsx-render edge was graded by the TypeScript-checker oracle, which binds each tag occurrence with ts.createProgram and lexical scope.

On main a50104c:

  • 29 repos are byte-identical: bulletproof, matx, mantis, minimal-kit, proshop, next-saas, t3-turbo, outline, crwn-clothing, binner, rr-examples (react-router 6.29.0), vispana, hathor-explorer, berry, cboard, chickadee, chushi, clean-arch, eshop-ddd, paydirt, persist-login, proshop-v2, ranking-app, react-boilerplate, redux-realworld, rr-auth-v6, takenote, vanlife and volun-mern.
  • excalidraw: −14 / +1. All 14 removals grade all-local (8 bound in the parent, 6 in ExampleApp around renderExcalidraw / renderTopRightUI). The +1 is one of the removed edges coming back: ExampleApp → Sidebar in ExampleSidebar.tsx, now with via: ExampleSidebar. That is the default import <ExampleSidebar> that really renders it. Before, the wrong <Sidebar> edge took that slot in the dedup.
  • refine-examples: −10 / +0. All 10 grade all-local (7 destructured props, 3 destructured locals).
  • No node, ref, file or non-jsx-render edge changes anywhere.

I merged main twice more and re-ran the A/B both times:

Both times, every repo's removed and added jsx-render edges matched the first run edge for edge, and the same 29 repos were byte-identical. Each pair of arms differed only in callback-synthesizer.js, so the diff is this change and nothing else.

Tests

__tests__/jsx-child-disambiguation.test.ts replaces the test that pinned the old behaviour (destructured names linked by name) with three tests:

  1. Renders nothing for a name the parent destructures: excalidraw's shape, including a nested renderSidebar; refine's layout props; a rename target; a pattern nested in a parameter's; an array pattern.
  2. Renders nothing for a name a function around the parent binds, unless that function declares it: a plain const in the enclosing function renders nothing; a component the enclosing function declares still renders (the binner shape); a callback's destructured Cell doesn't hide the Cell the table declares.
  3. Still renders the component for: a rename's key, a default value, a type literal (,-separated, after a pattern and after a plain parameter), an object argument, and a destructured require() and await import().

The new "renders nothing" tests fail on main. Mutations each break the assertions they guard:

Mutation Fails
destructuring off ExampleApp, renderSidebar, Layout, Nav
functions around the parent off renderSidebar, renderItem
declaredInside against the parent instead of the binding function renderNode
require/import() exception off LegacyPage, renderChart
a rename's key counted as bound Card

Full suite on the merged tree (main bb8a0f73 + this branch), Windows 11 x64, Node 22, kernel built, CODEGRAPH_KERNEL_EXPECT=1, --maxWorkers=4 --minWorkers=1: 618 files passed, 6 skipped; 7,499 tests passed, 80 skipped, 0 failed.

Cost

The jsxEdges pass was timed in-process with an instrumented build, adding up only the new paths: the loop over functions around the parent, and every destructuredBy.

Repo Pass CPU Functions-around loop destructuredBy
excalidraw ~0.65 s 0.7–1.1 ms 0.8 ms (16 calls)
outline ~1.4 s 1.7–1.9 ms 0.5–0.6 ms (26)
refine-examples ~3.6 s 2.3 ms 1.3 ms (121)
binner ~0.95 s 0.25 ms 0.13–0.18 ms (2)

That is at most about 4 ms per repo, 0.1–0.3% of the pass. Interleaved median runs of the whole pass (5 rounds per arm) swung by ±15% on this shared machine, with binner "faster" after, so they can't resolve a change this small. No regex is compiled per name; the scan is indexOf plus bracket walks capped at 10,000 characters.

Overlap: #2611 (open, @inth3shadows)

#2611 attributes JSX outside every function to the top-level value or the file. It edits the same post-check line:

(own ? ownLinesBind(name, names.src) : jsCodeBindsName(name, parent, file, line, ctx))

It also adds its own SpanTags = { tags, bare, src } with src set only for owning parents. Whichever lands second can resolve it this way:

Not in this PR

🤖 Generated with Claude Code

colbymchenry and others added 3 commits October 19, 2026 14:04
…binds, renders no JSX child

#2442 made a tag the parent binds itself, as a `const` or a parameter,
render only a component the parent declares inside itself. Destructuring
was left out, as name-matcher leaves it out for a bare call (`const { t }
= useI18n()` is that function more often than not), so a destructured tag
still linked any same-named component in the repository. For a tag that
is the wrong default: what a component destructures is a component its
props or a library hand in, which is no node here.

- refine's layouts, `({ children, Header, Footer, Sider, OffLayoutArea })
  =>`, rendered other examples' `Header`, `Footer`, `Sider` and
  `OffLayoutArea`; `const { Title } = Typography` (antd) and `const {
  Link } = useRouterContext()` rendered other examples' `Title` and `Link`.
- excalidraw's example app, `const { Sidebar, Footer, WelcomeScreen, … } =
  excalidrawLib`, rendered its own `ExampleSidebar.tsx` for the library's
  `Sidebar`, and `Footer.tsx` though the library exports `FooterCenter` as
  `Footer`. Its other tags landed on the library's components by luck.

A tag now checks the parent's code through its first tag for a
destructuring pattern that binds the name (`destructures`, read off the
code-only view): a declaration's or a parameter's, nested patterns, rest
elements, rename targets and array patterns included. A rename's key, a
default value and a type literal after a pattern bind nothing, nor does an
object argument or a control-flow head, and a declaration of `require(…)`
or `import(…)` binds the module's own export, which links as before.

The same check, and #2442's `const`/parameter check, now also reads the
functions around the parent: excalidraw's `renderExcalidraw` and
`renderTopRightUI` render what `ExampleApp` destructures. The function
that binds the name is the one whose own components a tag may still
render (`declaredInside`), so a helper rendering a component its enclosing
function declares (binner's `recursiveTreeItem` → `StyledTreeItem`) keeps
its edge.

Fresh before/after indexes on 31 repos (main a50104c): 24 jsx-render
edges removed (excalidraw 14, refine-examples 10), every one graded
all-local by the TypeScript checker (each tag a destructured local);
15 targets were wrong, 9 right by luck. One of them, ExampleApp →
ExampleSidebar's `Sidebar`, comes back with `via` ExampleSidebar, the
default import that renders it, which the wrong `<Sidebar>` edge used to
hide. No other edge, node, ref or file changes; 29 repos are
byte-identical.

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