Repository navigation
fix(react): a JSX tag renders what the file imports under that name - #2451
Open
colbymchenry wants to merge 7 commits into
Open
colbymchenry wants to merge 7 commits into
colbymchenry wants to merge 7 commits into
Conversation
A JSX tag was matched by name, with the file's import consulted only to
break a tie, so a monorepo's apps rendered each other's same-named
components (bulletproof-react: 179 of 559 jsx-render edges crossed apps
through `@/components/ui/*` barrels), a default import named like an
unrelated symbol bound that symbol, and a function component imported
under another name rendered nothing. The tag now resolves through the
import first (same-file declarations still win, and a name the file does
not import from the project keeps the old name-based rules). A value the
import names hands on what it wraps or aliases, a type renders nothing,
and a component reached under another name renders only where the name
is written as a tag, not as a type argument.
Following the import needed a precise default export: findExportedSymbol
guessed the module's first exported component or function, so
`export function loader` above `export default function Vans` made every
`import Vans` the loader. The default branch now reads the module's own
`export default` statement for JS-family files: a declaration, a name,
`export { X as default }`, what a wrapper call wraps (`observer(Card)`,
`connect(m)(Bar)`, `traceFunction(...)(fn)`), an instance's class
(`new Storage()`), a binding the module imports, or nothing for an
anonymous expression. A named lookup also follows an export clause that
forwards an import. Includes #2412's `{ default as X }` mapping (same hunk).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey-f490b0 # Conflicts: # CHANGELOG.md # src/resolution/callback-synthesizer.ts
…ed path first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode is the module's A template can hold statement-shaped text: react.dev's SandpackWithHTMLOutput.tsx writes two sandbox files' `export default function` lines above its own `export default memo(function ...)`, and reading only the first line left the module's default import with nothing. Every line-initial statement outside a block comment is now read, the first that names a node wins, and a function or class a statement declares binds only the node that starts there, so a template's `export default function formatHTML` can't reach the file's real formatHTML. The block-comment check reads the file once for all of its statements. Carries #2432's default-export tests unchanged (all pass), and adapts its multi-statement and declaration-position rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`export default createStore(reducer)` exports a store, but the wrapper
reading took `reducer` as the default, so `store.dispatch()` through the
default import linked the reducer. A wrapper call now hands on its
argument only when it is a component's name (`memo(Card)`,
`observer(function Settings…)`) or ends a curried chain
(`connect(mapState)(view)`, `traceFunction({…})(provision)`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey-f490b0 # Conflicts: # CHANGELOG.md # src/resolution/callback-synthesizer.ts # src/resolution/import-resolver.ts
…ey-f490b0 # Conflicts: # CHANGELOG.md # src/resolution/import-resolver.ts
This branch has not been deployed
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.
What
A JSX tag now renders what the file's import of it resolves to, instead of whichever component shares its name.
reactJsxChildEdges/jsxChildused to pick a tag's component by name and consult the import only to break ties: a unique name was taken even when the file imports the tag from a module that does not declare it, a tie fell through to the first candidate when the import named a barrel, and a renamed default import found nothing. The new order is:ctx.resolveImport→findExportedSymbol, through barrels, renames and default exports), for an import of a project module:export interface X+export const X = …), else the function the value hands on (const Avatar = AvatarWithHoverCard,observer(function X() {…})), else nothing;export default () => <Search {...props} />): the component its own module declares under the tag's name, else nothing;A component reached under another name (
import SharedEditor from '~/editor') renders only where the parent writes that name as a tag, not as a type argument (useRef<SharedEditor>). Each tag now resolves once per file instead of once per parent and tag. The per-span tag memo from #2424 now carries this "written as a tag" flag.Default exports had to become precise first
Following the import is only as good as
findExportedSymbol's default branch, and that branch guessed. It took the module's first exported component, thenexport default NAME;, then its first exported function or class. On a probe it returned the wrong node for 8 of 12 common shapes, for exampleexport function loaderaboveexport default function Vans→loader, andexport const Wrapper = styled…aboveexport default function Card→Wrapper. So for JS-family files the default branch now reads the module's ownexport defaultstatement and never guesses:export default function Vans/class Baz/Page;(as T/satisfies Tallowed) /export { Qux as default };memo(Foo),connect(mapState)(Bar),withRouter(connect(m)(Bar)),observer(function Settings() {…}),traceFunction({…})(accountProvisioner),styled(Button)\…`. A wrapped binding must be a function, class or component:createRouter(routes)does not exportroutes`;new Storage(): the class, whose members are the instance's;import Login from './Login'; export default Loginin anindex) is followed to that module;export default () => …, an object,requireNativeComponent('X')) is a node only when one stands exactly at the expression (React Native'scodegenNativeComponent). Otherwise it is nothing.Every line-initial
export defaultoutside a block comment is read, and the first one that names a node is the module's. A template can hold statement-shaped text: react.dev'sSandpackWithHTMLOutput.tsxhas two sandbox files'export default functionlines above its ownexport default memo(function …). A function or class a statement declares binds only the node that starts there, so a template'sexport default function formatHTMLcan't reach the file's realformatHTML. Both rules come from #2432. Each statement is read from a 4 KB window at its start, and one pass over the file sorts out block comments for all of them. SFC files (.vue/.svelte) and modules with noexport defaultstatement keep the old fallbacks. Separately, a named lookup now follows an export clause that forwards an import (import AdvancedMarker from './advanced-marker'; export { AdvancedMarker }), which the clause-alias index never could: import nodes are named by their source.Tests
__tests__/jsx-child-imports.test.ts, 13 cases. 10 fail onmain, each for the reason the bug report gives. The other 3 are guards for shapes the triage below turned up, and they pass onmaintoo:@/per app);{ default as Settings },import Settings from,{ ProfileRoute as Settings });loader/actionexported aboveexport default function, andexport default observer(Card)under another name;index(default andexport { X }clause) and a constant alias (const Badge = BadgeWithTooltip);codegenNativeComponentexported directly or through a const (guard);export default function …lines above the real statement, one of them naming a function the file also declares (react.dev's shape);createApppast an earlierhelper,traceFunction(…)(accountProvisioner), andexport default new Storage()(main resolvesStorage.get()tostorageKey).__tests__/default-export-declaration.test.tsis #2432's test file, carried unchanged. All 5 cases pass: data-router routes, an async handler, a class, a declaration andexport default Bannerbeating an exported styled component, anonymous defaults, andnew Service().npx tsc --noEmitis clean. On the branch after merging main: the JSX, Fabric/Paper, HOC, React Router, default-import, dead-code, #2424 and resolution suites pass (387/387). The full suite passed on the merged branch before the last commit (6,596 passed, 0 failed); the final run is in the comments below.Validation
Before/after
scripts/dump-graph.mjson freshcodegraph init -yindexes. Both arms have the same freshly built native kernel staged (TS/JS are kernel-routed). The base ismainat ed199e6, which already has #2412, #2423 and #2424; the other arm is this branch merged with it. An earlier run on 31c3328 + #2412 gave the same per-repo diffs, except vanlife, where #2423 adds routes. Edge counts are natural-key rows.Every removed jsx-render edge was triaged as re-targeted or lost:
Button×50,Spinner,Form,Link,Input, …). The new edge isAuthLayout as AuthLayoutComponent. The 12 left are out of scope: 9 tags imported from packages (react-error-boundary,react-router), one<Progress />in a comment, and two generic parameters (TableColumn<Entry>).<Text>→components/text, not a helper insidegh-banner. The 12 lost all pointed into other apps:import type { User }used as a generic,const Form = FormProvider,const Select = SelectPrimitive.Root. The 34 new are renamed imports (Sidebar as ShadcnSidebar),Svg*icon defaults, and the map barrels'export { X }clauses. The 4,200 remaining cross-app edges are package imports; another 208 are names that aren't imported (antd'sconst { Text } = Typography).<Text>resolve to a styled constant, where main boundEditableTitle's localText. 25<Header>and 21<Footer>are email templates whose default isexport default () => …, where main bound command-bar and explorer namesakes. There are also 8<Diff>→ an editor extension class, 6<Emoji>→ a model class, 2<EditorContainer>, and 2 type-only imports.Avatar→AvatarWithHoverCard(×28,const Avatar = AvatarWithHoverCard),{ Meta as DocumentMeta },export default WrappedTooltip, the defaultobserver(…)s, and model classes likeDocument/Desktopreplaced by the imported components.observer(function X_…)values and renamed defaults.{ Toast }→ToastComponent(Object.assign(ToastComponent, …)),Dimension/DragInput/DropdownMenuGroupdefaults, andindex.tsx'simport Footer from './components/footer/FooterCenter'; export { Footer }. The 3 lost were strangers (Sidebar/CommandPalettefrom other files).<CodeBlock>inCodeDiagramandPackageImportnow rendersCodeBlock/index.tsx'smemo(function CodeBlockWrapper…), whichimport CodeBlock from './CodeBlock'names. Main skipped that wrapper for the innerCodeBlock.MDXComponents' default import ofSandpackWithHTMLOutputstays at import 0.9, past the template's statements.<Drawer>renderedthemes/overrides/Drawer(the next app's, from the vite app). matx's<ConsecutiveSnackbar>and chickadee's<NotFound>rendered a styled helper (the "module's only component" fallback).The non-JSX changes come from the default-export reading and reach every default import:
@Relationdecorators boundgetInverseRelationsForModelClass;paginationboundpaginateQuery(41);lazyWithRetryboundisStaleChunkError;ToggleBlock.isEmpty()bound a free function. 349 server-sideLogger.info/warn/error/debugcalls went to the browser'sapp/utils/Logger.ts, because both tiers doclass Logger+export default new Logger().presentDocument/presentTemplatecalls went to namesakes inserver/tools/.traceFunction(…)(accountProvisioner)and the other commands, plus the presenters barrel (import presentPolicies from './policy'; export { presentPolicies }).[CodeFence, …]) had been bound to the first exported function, andfunction_refrejects a class, so these refs are now unresolved. The rest areexport default new Plugin(…),export default function (…), andexport default Sentryof a namespace import.Login/Vans/VanDetail/Dashboard/HostVans/HostVanDetailinstead ofaction/loader. That includes the/hostindex route and the threelayout:HostVanDetailrefs that fix(react-router): a JSX index route is the page at its parent's address, inside the layout around it #2423 added.getInjectorsandgithubDatadefaults re-target from the factory/saga exported above them. The 8 removedinstantiatesedges went from an anonymousexport default ({…}) => …to the class nested inside it.reducer(...)calls throughexport default noteSlice.reducerwere bound togetFirstNoteIdand are now unresolved.connect(…)(X)routes now resolve.export default (props) => <Search …/>is anonymous, so they now come from name matching (exact-match 0.5) instead of the import guess.Timing. In the pipeline, the
jsxEdgespass of a fullinittook 3.81 s → 4.22 s on refine examples, 2.11 → 2.20 s on outline, and 0.38 → 0.43 s on excalidraw; small repos changed by less than 15 ms. Run alone on a cold resolver, over 5 interleaved runs with the machine at about 70% load, CPU medians were:The extra work is import-path resolution, mostly
existsSyncprobes for each imported tag's specifier. In the pipeline those are mostly warm, because resolution has already resolved the same specifiers. Name queries per parent and tag drop, since each tag resolves once per file.Overlap with open PRs
export defaultdeclares, not the first exported function #2432 (a default import is whatexport defaultdeclares): fixes the same first-export guess in the same branch offindExportedSymbolWalk. This PR's reading now includes fix(js,ts): a default import is whatexport defaultdeclares, not the first exported function #2432's multi-statement preference and position-checked declarations, and carries its test file unchanged, so it covers everything fix(js,ts): a default import is whatexport defaultdeclares, not the first exported function #2432 does. It also adds wrapper calls,new X(),export { X as default }, following an imported binding, and no guess once a statement exists. The two PRs conflict in that function, so only one should land; which one, and whether to close the other, is the maintainer's call.reactJsxChildEdgesloop with the sameasTagexpression, and both extendjsxChild. The merged order should be own value → same-file declaration → import → name. fix(react): a component a file declares itself as a value is what its JSX renders #2420 also addsdefaultExportedName/wrappedName/wrappedComponentinframeworks/react.ts, which readexport defaultand wrapper calls a second way. Whichever PR lands second should route fix(react): a component a file declares itself as a value is what its JSX renders #2420's lazy-route lookup throughfindExportedSymbol's default branch, or through this PR'svalueBinding, so there is one reading.language/ per-filechildOfmemo, and drop theMap<name, asTag>gate.require('./x').defaultreaches an ES module's default export #2433 (require('./x').default): edits the same default branch. It factors the old guess chain intoesmDefaultExport(), built ondefaultExportBindingNode, which this PR replaces withdefaultStatement/defaultStatementNode. Whichever lands second should pointesmDefaultExport(and itshasEsmDefaultExportcheck) at the statement reading, so arequire().defaultgets the precise default too.import-resolver.ts(isExternalImportoptions; a sync tail for import bindings). At most there's an adjacent-line conflict in the import block.Left out (follow-ups)
react-router'sLinkin bulletproof. The task asked to keep that fallback, and a chip from the fix(react): a component a file declares itself as a value is what its JSX renders #2420 session covers it.Array<Document>) still bind as on main. Only renamed imports are gated here; fix(react): a type argument or a name the component binds itself renders no JSX child #2442 covers the rest.baseUrl-only import (from 'src/components') doesn't resolve, so its tag falls back to the name (one edge in refine's examples). This is the knownpath-aliases.tsgap.🤖 Generated with Claude Code