Repository navigation
fix(react): a component a file declares itself as a value is what its JSX renders - #2420
Merged
Merged
Conversation
This was referenced Oct 7, 2026
colbymchenry
added a commit
that referenced
this pull request
Oct 7, 2026
…ers no JSX child (#2442) The jsx-render pass reads tag names off a parent's source with a pattern, and two kinds of name it read that way linked a same-named class or component elsewhere: - A name written only in a type argument or type parameter list: outline's `<PaginatedList<Document> …>` and `useState<User>()`, or bulletproof-react's generic `<Entry extends BaseEntity>(…) =>`. A `<Name` right after an identifier opens a type argument list (the rule #2420 applies to the values a file declares, here for every name), and a `<Name extends …` opens a type parameter list. - A name the parent binds itself by its first tag, a `const`/`let`/`var` or a parameter (name-matcher's `jsCodeBindsName`, now exported): outline's `const Content = variant === "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent`, `(Widget, index) => <Widget />`. Such a tag renders only a component the parent declares inside itself, else nothing. The check runs only for names the parent also writes outside a tag. Destructured names still link by name, as for a destructured call. Fresh before/after indexes on 13 repos (main ed199e6): 66 jsx-render edges removed and none added; no other edge, node, ref or file changes. Every removal checks out against the TypeScript compiler (38 names with no JSX tag in the parent, 28 tags bound to a local); 64 of the 66 targets were wrong, 2 right by luck. mantis, proshop_mern, next-saas-starter and create-t3-turbo are byte-identical, and Screens is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 7, 2026
colbymchenry
added a commit
that referenced
this pull request
Oct 14, 2026
…mport (#2558) A lazily loaded folder whose index passes on a page it imports linked nothing: DigiShop.Frontend's `src/layout/index.ts` does `import { UserLayout } from './UserLayout'; export { UserLayout }`, and its routes load `await import('@/layout')`. exportedComponent, the barrel walk behind lazy routes, React.lazy values and lazy layouts (#2436, #2452, #2480), read the export clause as naming a declaration in the barrel, which has none: the name is an import binding. `import Login from './Login'; export default Login` and `export { Page as Component }` of an imported Page failed the same way, so all 27 lazy route refs in DigiShop's App.tsx stayed failed. A binding the module exports but does not declare is now followed through the module's import of it, a default import as `default` and a named one by the name it imports, with each file read added to `reads` so lazyModules still redraws the route. A namespace import or a package's binding links nothing. `export default withAuth(Page)` hands over a call, whose first identifier ownExport reads; that was harmless while only same-file declarations counted, so a default is followed only when the statement is the binding itself (exportsBinding), or the route would link the HOC. #2451 (open) adds the same step to the general import walk; #2420 (open) carried it in a lazyRouteComponent rewrite that exportedComponent superseded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… JSX renders
A PascalCase name that a .tsx/.jsx file declares as a module-level value is
that declaration wherever the file uses it: a JSX tag, a route's element, a
layout or a TSX type. Its initializer decides what it renders, in order:
- the component of the module its lazy loader loads, or the export the
loader picks, in either branch of a conditional;
- the function it wraps (`observer(function X() {…})`,
`forwardRef(Inner) as {…}`);
- else the declaration itself.
Before, the lookup by kind skipped the value and took a lone same-named
component anywhere, often in another app of the repository.
- Pattern 1 asks declaredComponent about every PascalCase reference, not
only route references. layoutComponent and the jsx-render synthesizer's
jsxChild ask it too.
- valueLoader reads a value's loader with loaderValue (#2480).
- defaultExportedName reads `export default observer(Login)`.
- lazyValueModules records the modules a file's lazy values load as
synthesis inputs, so a sync that changes one of them redraws the tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry
force-pushed
the
claude/interesting-hermann-dcee7f
branch
from
October 14, 2026 20:39
3c4fb18 to
5bb579c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A
.tsx/.jsxfile can declare a component as a value:const RegisterPage = Loadable(lazy(() => import('pages/auth/Register'))), ordynamic(async () => (await import('../excalidrawWrapper')).default);const Settings = observer(function Settings() {…}),React.memo(TableBody, areEqual)orforwardRef(Inner) as {…};const DialogPortal = DialogPrimitive.Portal,createContext(…).Such a value is a
constantnode. The lookups behind React component references (resolveComponent) and JSX children (jsxChild) keep onlycomponent,functionandclassnodes, so they skipped the value and took a lone same-named component anywhere in the repository. #2400 fixed this for one case only: references out of route nodes. #2452 later carried that case through sync.Plain JSX (
<RegisterPage />) never reachesreactResolver.resolve. Those edges come from thejsx-rendersynthesizer (reactJsxChildEdges→jsxChild), which had the same blind spot. Some examples:examples/with-nextjs,<Excalidraw />comes fromconst Excalidraw = dynamic(…), but the edge went to the docs site'sExcalidraw.const Emoji = lazy(() => import('…/emoji_picker').then(({ Emoji }) => ({ default: Emoji })))renderedcomponents/emoji's unrelatedEmoji.let EmojiPicker, Emoji; // load asynchronouslyrendered the async loader functions ofui/util/async-components.Fix
One lexical rule for every React component name.
declaredComponentinframeworks/react.tsis exported and now returns{ declaration, component }. If the file declares the name as a module-level value, the name binds to that declaration, never to another file's component. The same rule now applies:.tsx/.jsxfile, not just route references: a route'selement, a Next.js page, a TSX type;layoutComponent, since alayout:reference has no name-matching fallback;jsx-rendersynthesizer'sjsxChild, where the lookup is memoized per file and name.What the value renders, read from its initializer:
valueLoaderreads the loader with fix(react-router): a lazy route links the page its loader picks #2480'sloaderValue, the same reader a data router'slazygets. That covers:lazy(() => import('./x')), wrapped (Loadable(lazy(…)),dynamic(…, { ssr: false }),Loadable({ loader })) or not;(await import('./x')).default;.then((m) => ({ default: m.Chart }))or.then(({ Emoji }) => ({ default: Emoji }));IS_DEV ? React.lazy(…) : null.observer(function Profile_() {…})). Or the same-file component handed to a wrapper call:observer(Inner),connect(m)(Inner), or a component ahead of options (React.memo(TableBody, areEqual),withAuthZ(Page, { … })). A type assertion on the call is skipped (as typeof Inner,as { … }). An import inside a function body the value wraps loads nothing the value renders.Lazy modules. A module whose default export is
observer(Login),connect(…)(Login)ormemo(Login, eq)hands overLogin(defaultExportedName, used byexportedComponent). A value found there is unwrapped too. A.jsxfile's./layout/MainLayoutresolves with the file's own extensions (MainLayout/index.jsx) before TypeScript's.Sync. A tag's edge now depends on the modules that the file's lazy values load. So
collectInputsrecords those modules, with the barrels on the way, as synthesis inputs (lazyValueModules). A sync that gives one of them its default export redraws the tag the way a fresh index draws it; without this,sync-lazy-route-value.test.ts's default-export case diverged from the fresh index.lazyModulesalso covers a layout the route file declares as a lazy value.Converging with what landed since
This branch is a single commit on
main9b23709, rebuilt from scratch rather than rebased:declaredLoader,lazyModules,reopenRouteModuleReaders): kept;declaredLoadernow goes throughvalueLoader.loaderValue,pickedExport): reused for a value's loader. This PR's ownimport()regex is gone.exportedComponentfollowsimport X …; export default X): already onmain, so this PR's own forwarding code was dropped.opensTag,writtenBare,importedFromPackage,JSX_TAG_RE,tagsBySpan):main's loop is kept as is. The only change is thatjsxChildtakesownValueOf. This PR's ownasTagis gone, sinceopensTagcovers type arguments.Open overlap: #2451 (import-first
jsxChild) edits the start ofjsxChildtoo. When it rebases, the own-value check should stay first. A module can't both import and declare a name, so the two rules never compete for one tag.Validation
Every repo was indexed fresh (
init -y) twice, withmain9b23709 and with this branch, and the graphs compared withscripts/dump-graph.mjs.examples/with-nextjs: the pages router's<Excalidraw/>→ExcalidrawWrapper(wasdev-docs'Excalidraw); the app router's<ExcalidrawWithClientOnly/>→ExcalidrawWrapper(new);TTDDialog→ itsTTDDialogBasevalue (new)<DialogPortal>/<DrawerPortal>→ the same file'sDialogPrimitive.Portalaliases, in each of the three apps<LazyChart>→ itslazy(() => import('react-apexcharts'))declaration (a package, so the declaration)<ThemeContext value>→ the same file'screateContextoutline.
/(twice),/createand/s/:shareIdnow reach the screens their lazy declarations load, not the declarations:SharedScene, throughexport default observer(SharedScene);Login, throughscenes/Login/index.ts'simport Login from "./Login"; export default Login.Table's<TableView>is nowTableViewInner, not ProseMirror'sTableViewclass;UserHoverCard's<Profile>is now its ownobserver(function Profile_…), not the settings page;ShareButton's<SharePopover>is now the lazily loadedSharing/Documentone, notSharing/Collection's.HeadingPrefixMenuItem_,CollectionScene_,SelectableRow_, …;ObservedCell,AvatarEditorDialog,SVGIcon, …;DocumentSidebarContent→Comments,History.mastodon.
compose/redesign/emoji.tsx,<Emoji>(×3) and<EmojiPicker>now reach the exports theirlazy(() => import('…/emoji_picker').then(({ Emoji }) => …))loaders pick. They had reachedcomponents/emoji's unrelatedEmojiand theasync-componentsloader function.MediaAttachments's<Video>likewise reachesfeatures/video's component, not the loader.let.emoji_picker_dropdown.jsxdeclareslet EmojiPicker, Emoji; // load asynchronouslyand assigns them from emoji-mart at runtime. Its tags had gone to those same two wrong targets and now bind to the declaration.LazyStatusRedesign,LazyStatusLegacy,ComposeLazyForm,LazyColumnsAreaRedesign) to their modules' components;forwardRef(ComboboxWithRef) as {…}andinjectIntl(EmojiPickerMenuImpl);<MenuContext>, a React 19 context provider.SigNoz.
cmdKPalette.tsx's twoIS_DEV ? React.lazy(…) : nulldevtools. The loader is read, but its module path is out of reach (see Known limits).const NumberPanelRenderer = BaseNumberPanelRenderer as React.FC<…>.<ResponsiveGridLayout>×2 → theirWidthProvider(GridLayout)declarations;<ViewRoleContent>→ViewRoleContentInner, throughwithAuthZContent(Inner, {…});<MemoizedTableBody>→TableBody, throughReact.memo(TableBody, eq).TableV3already reachedTableBodythrough<TableBody>, so that edge is only relabeled and counts as the fourth removal.Precision. A script checks every added edge against the source: 69 of 69 check out. It accepts a
jsx-renderedge only when:<Namesits in tag position inside the parent;import('…')names.Every removed edge is a replacement: the same source and tag now reach a different target. I read the source behind each one.
Index time. Measured interleaved on this shared box (run 1 / run 2):
mainTests
__tests__/jsx-child-disambiguation.test.tsgains 8 cases, each with a decoy component elsewhere:dynamic(async () => (await import(…)).default);.thenpick through anexport *barrel;export default observer(Login);observer(function …)andobserver(Inner) as typeof Inner;forwardRef(…) as {…}andReact.memo(Rows, eq);__tests__/react-router.test.tsgains a layout loaded lazily fromMainLayout/index.jsx, beside another app'sMainLayout.__tests__/sync-lazy-route-value.test.ts: scenario 1 also expectsApp's<DocsPage>to reachDocs.tsx's component. That is the new jsx-render edge; sync matches the fresh index.main's source, all 11 new or changed cases fail.tscis clean. The 20 React, JSX, router and sync test files pass (23 files, 487 of 487).EBUSYteardown failure inmcp-initialize.test.ts, a known flake on this Windows box, which passed 3 of 3 when rerun alone.Known limits
const Icon = item.icon; <Icon />) has no node, so the old lookup still applies to it.const NumberPanelRenderer = BaseNumberPanelRenderer as React.FC<…>in a SigNoz test is one example.import(`./pages/${name}`), or an import only the app's build resolves. SigNoz's two devtools values fall here. Theirlib/authz/…paths come fromfrontend/tsconfig.json'spaths, whichisExternalImportdoesn't consult: it reads only the root config's aliases, so those imports count as npm packages. That gap predates this change and affects all of SigNoz's alias imports, so I'm raising it separately.Docs
docs/design/framework-coverage.md.[Unreleased]→### Fixes.🤖 Generated with Claude Code