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 ;',
+ '}',
+ ]);
+ // bulletproof-react's layout and binner's inventory: markup commented out.
+ write('components/Drawer.tsx', component('Drawer'));
+ write('components/Progress.tsx', component('Progress'));
+ write('components/Layout.jsx', [
+ "import { Drawer } from './Drawer';",
+ "import { Progress } from './Progress';",
+ 'export function Layout({ children }) {',
+ ' /**/',
+ ' return (',
+ '
',
+ ' {/* */}',
+ ' {children}',
+ '
',
+ ' );',
+ '}',
+ ]);
+ await index();
+ expect(renders('useRoutesImpl')).toEqual(['Outlet']);
+ expect(renders('useCarousel')).toEqual([]);
+ expect(renders('ToggleTheme')).toEqual([]);
+ expect(renders('Layout')).toEqual(['Drawer']);
+ });
+
+ it('renders nothing from a TypeScript .ts file', async () => {
+ // outline's OpenSearch description, an XML template in a `.ts` route.
+ write('app/components/Image.tsx', component('Image', 'img'));
+ write('app/components/Description.tsx', component('Description', 'p'));
+ write('server/routes/opensearch.ts', [
+ 'export function opensearchResponse(baseUrl: string): string {',
+ ' return `',
+ '',
+ ' Search Outline',
+ ' ${baseUrl}/images/favicon-16.png',
+ '`;',
+ '}',
+ ]);
+ // A type assertion names a class component. The regex right after
+ // `if (…)` reads as a division, so the file can't be followed and is read
+ // as it is: only its language says `` is no tag.
+ write('app/editor/Editor.tsx', [
+ "import * as React from 'react';",
+ 'export class Editor extends React.Component {',
+ ' render() {',
+ ' return ;',
+ ' }',
+ '}',
+ ]);
+ write('app/editor/focus.ts', [
+ "import { Editor } from './Editor';",
+ '',
+ 'export function focusEditor(view: unknown, closing = "
") {',
+ " if (closing) /'/.test(closing);",
+ ' const editor = view;',
+ ' return editor;',
+ '}',
+ ]);
+ await index();
+ expect(renders('opensearchResponse')).toEqual([]);
+ expect(renders('focusEditor')).toEqual([]);
+ });
+
+ it('still renders the tags after an apostrophe, a URL or a backtick in JSX text', async () => {
+ for (const name of ['Link', 'Docs', 'Kbd', 'Field', 'Fake']) write(`app/${name}.tsx`, component(name));
+ write('app/Signup.tsx', [
+ "import { Link } from './Link';",
+ "import { Docs } from './Docs';",
+ "import { Kbd } from './Kbd';",
+ "import { Field } from './Field';",
+ '',
+ 'const first = (items: T[]) => items[0];',
+ "const slug = (s: string) => s.replace(/[`'\"]/g, '').replace(/\\//g, '-');",
+ 'const note = `${`nested ${first([1])}`} `;',
+ '',
+ 'export function Signup() {',
+ ' return (',
+ ' ',
+ ' );',
+ '}',
+ ]);
+ await index();
+ expect(renders('Signup')).toEqual(['Docs', 'Field', 'Kbd', 'Link']);
+ });
+});
+
+describe('jsxCode', () => {
+ const TAG = /<([A-Z][A-Za-z0-9_]*)(?=[\s/><])/g;
+ const tags = (code: string) => [...code.matchAll(TAG)].map((m) => m[1]);
+
+ it('blanks comments and literal text, and keeps every offset and line break', () => {
+ const source = [
+ '/** Renders a in an . */',
+ 'export function Page({ items }) {',
+ " const re = //g, s = 'a ', t = ` ${items.map((i) => )}`;",
+ ' return (',
+ ' ',
+ " {/* */} Don't go to https://a.b/c",
+ ' ',
+ ' );',
+ '}',
+ ].join('\r\n');
+ const code = jsxCode(source)!;
+ expect(code).not.toBeNull();
+ expect(code.length).toBe(source.length);
+ expect([...code].flatMap((c, i) => (c === '\n' || c === '\r' ? [i] : []))).toEqual(
+ [...source].flatMap((c, i) => (c === '\n' || c === '\r' ? [i] : []))
+ );
+ expect(tags(source)).toContain('Fake');
+ expect(tags(code)).toEqual(['Item', 'Shell', 'Link']);
+ });
+
+ it('reads a `<` that opens a type parameter list as an operator', () => {
+ const source = [
+ 'type Fn = (x: T) => T;',
+ 'interface Call { (x: T): T }',
+ 'const first = (items: T[]) => items[0];',
+ "const Table = ({ rows }) => ;",
+ 'const c = i++ / 2, d = x! < y;',
+ ].join('\n');
+ const code = jsxCode(source)!;
+ expect(code).not.toBeNull();
+ expect(tags(code)).toEqual(['T', 'T', 'Entry', 'Grid']);
+ });
+
+ it('is null for a file it cannot read as one program', () => {
+ expect(jsxCode('const a =
a } b
;')).toBeNull();
+ expect(jsxCode('function f() {\n return 1;\n')).toBeNull();
+ expect(jsxCode("const s = 'open\nconst b = 1;")).toBeNull();
+ expect(jsxCode('const a = ;')).toBeNull();
+ });
+});
diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts
index 9f2818c7d2..3dc07280d4 100644
--- a/src/resolution/callback-synthesizer.ts
+++ b/src/resolution/callback-synthesizer.ts
@@ -41,6 +41,7 @@ import { enclosingFn, makeLineAt } from './synth-utils';
import { resolveImportPath } from './import-resolver';
import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher';
import { declaredComponent, exportedComponent, lazyValueModules } from './frameworks/react';
+import { jsxCode } from './jsx-code';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -52,6 +53,8 @@ const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
const SETSTATE_RE = /this\.setState\s*\(/;
const FLUTTER_SETSTATE_RE = /\bsetState\s*\(/; // Flutter: setState((){…}) / this.setState
const JS_FAMILY = ['typescript', 'javascript', 'tsx', 'jsx'];
+/** The languages that can write JSX: not TypeScript's `.ts` files. */
+const JSX_FAMILY = ['tsx', 'jsx', 'javascript'];
// A tag's name ends at whitespace, `/` or `>`, or at the `<` of the type
// arguments a generic component's tag passes: `
// items={…} />` renders PaginatedList. The lookahead leaves that `<` to open
@@ -1930,7 +1933,9 @@ function declaredInside(ctx: ResolutionContext, name: string, parent: Node, chil
* so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the
* JSX hop. Link parent → each capitalized JSX child it renders. File-oriented
* (read each JSX file once). Precision gates: the parent must write the name as
- * a tag, not only in a type argument or parameter list (`useState()`,
+ * a tag in its code, not in a comment, a string or a template (react-router's
+ * "You cannot render a inside another "; `jsxCode`), and
+ * not only in a type argument or parameter list (`useState()`,
* `(…) =>`; outline's ``
* read its `Document` model class as a child), and the name must resolve to a
* component/function/class node, or to a value the file declares itself (see
@@ -1966,9 +1971,11 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
const content = ctx.readFile(file);
if (!content || (!content.includes('') && !content.includes('/>'))) continue; // JSX-file gate
// File-level language gate, not merely a project-level one: mixed C/JS
- // monorepos must not interpret `""` inside C as JSX (#1560).
+ // monorepos must not interpret `""` inside C as JSX (#1560). A
+ // TypeScript `.ts` file holds no JSX either: a `` there is a type
+ // assertion or text, like outline's OpenSearch XML template.
const parents = ctx.getNodesInFile(file).filter(
- (n) => PARENT_KINDS.has(n.kind) && JS_FAMILY.includes(n.language)
+ (n) => PARENT_KINDS.has(n.kind) && JSX_FAMILY.includes(n.language)
);
if (parents.length === 0) continue;
// Read once per file, and only when a name actually turns out ambiguous.
@@ -1985,7 +1992,8 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
// the same lines share them: every function of a minified bundle spans
// its one line, and reading that line again per function took 9 to 25 s
// over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The
- // file is split once, not once per function.
+ // file is split once, not once per function, and read as code once: a
+ // tag in a comment or a string is text (`jsxCode`).
let lines: string[] | null = null;
// Each name a span holds, and the line of its first tag: 0 while only type
// argument or parameter lists name it (`opensTag`). `bare` holds the
@@ -1997,7 +2005,7 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
let names = tagsBySpan.get(span);
if (!names) {
names = { tags: new Map(), bare: new Set() };
- lines ??= content.split('\n');
+ lines ??= (jsxCode(content) ?? content).split('\n');
const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n');
if (src.includes('') || src.includes('/>')) {
let line = parent.startLine;
diff --git a/src/resolution/jsx-code.ts b/src/resolution/jsx-code.ts
new file mode 100644
index 0000000000..f09455e4dc
--- /dev/null
+++ b/src/resolution/jsx-code.ts
@@ -0,0 +1,508 @@
+/**
+ * A JavaScript or JSX file's code, for a scan that reads JSX tags off it with a
+ * pattern.
+ *
+ * Text that only mentions a tag renders nothing: a JSDoc example, markup
+ * commented out (`{/* *\/}`), an error message ("You cannot
+ * render a inside another "), an attribute's value. So
+ * comments, and the contents of string, template and regex literals and of
+ * JSX attribute strings, are blanked. Every character keeps its offset and
+ * every line break its place, so a match in the result sits at the same line
+ * and column as in the source.
+ *
+ * Inside an element, a quote, a slash or a backtick is text: `
Don't have an
+ * account? `, `https://…`. Read as code, that
+ * apostrophe opens a string that blanks the `(x: T) =>`, `type Fn = (x: T) => T`) reads as an element
+ * only until it fails to: the lexer then reads it again as an operator.
+ *
+ * Null when the file does not read as one program that way: a string still
+ * open at the end of its line, a comment, template or element still open at
+ * the end of the file, braces that don't balance, a closing tag that names
+ * another element, or a `` or `/>` among code. A scan should then read the
+ * source as it is.
+ */
+
+/**
+ * What the lexer reads: code, a template's text, an element's opening tag (its
+ * attributes), or its children (JSX text, tags and `{…}`).
+ */
+type Mode = 'code' | 'template' | 'tag' | 'children';
+
+interface Frame {
+ mode: Mode;
+ /** Code: braces opened in this frame and not closed yet. */
+ braces: number;
+ /** Tag and Children: the element's name, which its closing tag repeats. */
+ name: string;
+}
+
+/** Where an element opened among code: what to undo to read its `<` again. */
+interface Root {
+ at: number;
+ depth: number;
+ blanks: number;
+}
+
+/** Keywords an expression follows: a `<` after one opens an element, a `/` a regex. */
+const BEFORE_EXPRESSION = new Set([
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw',
+ 'case', 'do', 'else', 'yield', 'await', 'default',
+]);
+
+/** How many elements may fail to read before the file is read as it is. */
+const MAX_REREADS = 64;
+
+const isSpace = (c: number): boolean =>
+ c === 32 || c === 10 || c === 13 || c === 9 || c === 11 || c === 12 || c === 0xa0 || c === 0xfeff;
+const isIdStart = (c: number): boolean =>
+ (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 36 || c === 95 || (c > 127 && !isSpace(c));
+const isIdPart = (c: number): boolean => isIdStart(c) || (c >= 48 && c <= 57);
+/** A JSX name's characters: an identifier's, and the `-`, `.` and `:` between its parts. */
+const isNamePart = (c: number): boolean => isIdPart(c) || c === 45 || c === 46 || c === 58;
+
+export function jsxCode(src: string): string | null {
+ const n = src.length;
+ /** Ranges to blank, as start and end pairs, in source order. */
+ const blanks: number[] = [];
+ const stack: Frame[] = [{ mode: 'code', braces: 0, name: '' }];
+ /** The elements opened among code that are still open, innermost last. */
+ const roots: Root[] = [];
+ /** `<`s that failed to read as an element. */
+ const operators = new Set();
+ let i = 0;
+ /** Whether the last token ends an expression: a `<` then compares, a `/` divides. */
+ let exprEnd = false;
+ /** Whether the last token is a `.`, so a keyword is a property's name. */
+ let afterDot = false;
+ let rereads = 0;
+ let steps = 0;
+ const budget = 4 * n + 65536;
+
+ const at = (k: number): number => src.charCodeAt(k);
+ const skipSpace = (k: number): number => {
+ while (k < n && isSpace(at(k))) k++;
+ return k;
+ };
+ const nameEnd = (k: number): number => {
+ while (k < n && isNamePart(at(k))) k++;
+ return k;
+ };
+ const lineEnd = (k: number): number => {
+ while (k < n && at(k) !== 10 && at(k) !== 13) k++;
+ return k;
+ };
+ const blank = (from: number, to: number): void => {
+ if (to > from) blanks.push(from, Math.min(to, n));
+ };
+ const codeFrame = (): void => {
+ stack.push({ mode: 'code', braces: 0, name: '' });
+ exprEnd = false;
+ afterDot = false;
+ };
+
+ /**
+ * Undo back to the innermost element still open among code and read its
+ * `<` again as an operator. False when there is none, or too many have
+ * failed: the file is then read as it is.
+ */
+ const reread = (): boolean => {
+ const root = roots.pop();
+ if (!root || ++rereads > MAX_REREADS) return false;
+ stack.length = root.depth;
+ blanks.length = root.blanks;
+ for (const k of operators) if (k > root.at) operators.delete(k);
+ operators.add(root.at);
+ i = root.at;
+ exprEnd = false;
+ afterDot = false;
+ return true;
+ };
+
+ /** Open the element whose `<` is at `lt`. False when it cannot be one. */
+ const openElement = (lt: number): boolean => {
+ if (at(lt + 1) === 62) {
+ stack.push({ mode: 'children', braces: 0, name: '' });
+ i = lt + 2;
+ return true;
+ }
+ let k = nameEnd(lt + 1);
+ const name = src.slice(lt + 1, k);
+ // `(…) =>`: a generic arrow function's type parameters.
+ const next = skipSpace(k);
+ if (next > k && src.startsWith('extends', next) && isSpace(at(next + 7))) return false;
+ if (at(k) === 60) {
+ // `