From bde53872f48a50ee77c9d12b3829728c47428bfa Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 9 Oct 2026 21:20:21 -0500 Subject: [PATCH 1/3] fix(react): a tag written in a comment or a string renders no JSX child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jsx-render pass read tag names off each parent's raw lines, so text that only mentions a tag rendered it: react-router's hooks rendered the `Router`, `Routes` and `Route` their JSDoc, comments and invariant messages name ("You cannot render a inside another "), shadcn's `useCarousel` the `Carousel` its error names, and layouts the markup commented out of them (`{/* */}`). The pass now scans a code-only view of each file (`jsxCode`, new in src/resolution/jsx-code.ts): comments and the contents of string, template and regex literals and of JSX attribute strings are blanked, offsets and line breaks kept. The lexer follows JSX elements, so a quote, slash or backtick in JSX text (`Don't`, `https://…`) stays text, and it re-reads a `<` that opens a type parameter list (`(x: T) =>`) as an operator. A file it can't read as one program (an unbalanced brace, a string open at a line's end, a mismatched closing tag) is scanned as it is, as before. TypeScript `.ts` files are skipped outright: they can't hold JSX. Checked against TypeScript's parser on 36 React repos (7,649 files, 61,668 ` --- __tests__/jsx-child-disambiguation.test.ts | 230 ++++++++++ src/resolution/callback-synthesizer.ts | 18 +- src/resolution/jsx-code.ts | 508 +++++++++++++++++++++ 3 files changed, 751 insertions(+), 5 deletions(-) create mode 100644 src/resolution/jsx-code.ts diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts index 3a718b9a16..a5d669e7b6 100644 --- a/__tests__/jsx-child-disambiguation.test.ts +++ b/__tests__/jsx-child-disambiguation.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { CodeGraph } from '../src'; +import { jsxCode } from '../src/resolution/jsx-code'; /** * A JSX tag names ONE component, and the file it is written in says which: @@ -1002,3 +1003,232 @@ export default React.forwardRef(Button); expect(renders('Deck')).toEqual(['Card components/Card.js:3']); }); }); + +/** + * The pass reads tag names off a parent's source with a pattern, so it also + * read the tags that text only mentions. react-router's hooks rendered the + * `Router`, `Routes` and `Route` their invariant messages and comments name + * ("You cannot render a inside another "), shadcn's + * `useCarousel` the `Carousel` its error names, and a layout the `` + * commented out of it. A tag in a comment, or in a string, template, regex or + * attribute value, is text; so is anything in a TypeScript `.ts` file, which + * holds no JSX at all. + */ +describe('JSX child: a tag written in a comment or a string', () => { + let dir: string; + let cg: any; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-text-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}'); + }); + + afterEach(() => { + cg?.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (rel: string, lines: string[]) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, lines.join('\n') + '\n'); + }; + const component = (name: string, tag = 'div') => [`export function ${name}() {`, ` return <${tag} />;`, '}']; + + async function index(): Promise { + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + } + + /** The names a jsx-render edge out of `parent` points at. */ + const renders = (parent: string): string[] => + cg.db.db + .prepare( + `SELECT t.name AS r FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + ORDER BY r` + ) + .all(parent) + .map((row: any) => row.r); + + it('renders nothing for a tag in a comment, a string, a template or a regex', async () => { + write('router/components.tsx', ['Router', 'Routes', 'Route', 'Outlet', 'Blog'].flatMap((n) => component(n))); + write('router/hooks.tsx', [ + "import { Outlet } from './components';", + '', + 'export function useRoutesImpl(routes, parentPath) {', + ' invariant(', + ' useInRouterContext(),', + ' `useRoutes() may be used only in the context of a component.`', + ' );', + " // You won't get a warning about 2 different under a :", + ' //', + ' // } />', + ' warning(', + ' !parentPath,', + ' `You rendered descendant (under ) ` +', + ' "but the parent route path has no trailing \\"*\\"."', + ' );', + ' const selfClosing = //;', + ' return ;', + '}', + ]); + // shadcn's carousel hook, and excalidraw's docs theme toggle. + write('components/ui/carousel.tsx', [ + "import * as React from 'react';", + 'const CarouselContext = React.createContext(null);', + 'export function useCarousel() {', + ' const context = React.useContext(CarouselContext);', + " if (!context) throw new Error('useCarousel must be used within a ');", + ' return context;', + '}', + 'export function Carousel({ children }) {', + ' return {children};', + '}', + ]); + write('docs/Excalidraw.tsx', component('Excalidraw')); + write('docs/ToggleTheme.tsx', [ + 'export function ToggleTheme({ onThemeChange }) {', + ' if (!onThemeChange) {', + ' throw new Error("` props.onThemeChange` must be defined when `theme` is set");', + ' }', + ' return