fix(react): a tag written in a comment or a string renders no JSX child (#2564)

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 <Router> inside another <Router>"),
shadcn's `useCarousel` the `Carousel` its error names, and layouts the
markup commented out of them (`{/* <Progress /> */}`).

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 (`<T,>(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. The lexer's state lives in a class whose methods every file
shares: closures made per file sent V8 back to recompile it for file
after file.

Checked against TypeScript's parser on 36 React repos (7,649 files,
62,377 `<Name` matches): no real tag blanked, no file given up on, 614
non-JSX matches removed. Graph A/B on 12 repos: -36 jsx-render edges,
+0, nothing else changed; the TypeScript oracle grades all 36 as tags in
comments or strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-15 00:46:34 +00:00
committed by GitHub
parent 3f714bcc48
commit eec9288a56
4 changed files with 763 additions and 5 deletions
+1
View File
@@ -142,6 +142,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph sync` and the file watcher now link a lazily loaded React Router route to its page when the page appears, or gains the export the route reads, in a folder below the one the route loads, like `src/pages/Home/index.tsx` behind `import('@/pages')`. Before, such a route kept linking nothing until the folder's own `index.ts` changed or the project was re-indexed, although a fresh index linked it.
- In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way.
- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <Widget />)`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading.
- In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* <Progress /> */}`, or an error message like react-router's "You cannot render a <Router> inside another <Router>" or shadcn's "useCarousel must be used within a <Carousel />". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `<p>Don't have an account? <Link to="/signup">`, still links as before. Re-index React projects after upgrading.
- In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading.
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
+230
View File
@@ -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<HTMLButtonElement, object>(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 <Router> inside another <Router>"), shadcn's
* `useCarousel` the `Carousel` its error names, and a layout the `<Progress />`
* 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<void> {
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 <Router> component.`',
' );',
" // You won't get a warning about 2 different <Routes> under a <Route>:",
' //',
' // <Route path="blog" element={<Blog />} />',
' warning(',
' !parentPath,',
' `You rendered descendant <Routes> (under <Route path="${parentPath}">) ` +',
' "but the parent route path has no trailing \\"*\\"."',
' );',
' const selfClosing = /<Router \\/>/;',
' return <Outlet />;',
'}',
]);
// 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 <Carousel />');",
' return context;',
'}',
'export function Carousel({ children }) {',
' return <CarouselContext.Provider value={{}}>{children}</CarouselContext.Provider>;',
'}',
]);
write('docs/Excalidraw.tsx', component('Excalidraw'));
write('docs/ToggleTheme.tsx', [
'export function ToggleTheme({ onThemeChange }) {',
' if (!onThemeChange) {',
' throw new Error("`<Excalidraw/> props.onThemeChange` must be defined when `theme` is set");',
' }',
' return <button onClick={onThemeChange} />;',
'}',
]);
// 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 }) {',
' /*<Progress value={0} />*/',
' return (',
' <div>',
' {/* <Progress /> */}',
' <Drawer>{children}</Drawer>',
' </div>',
' );',
'}',
]);
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 `<?xml version="1.0" encoding="UTF-8"?>',
'<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">',
' <Description>Search Outline</Description>',
' <Image width="16" height="16">${baseUrl}/images/favicon-16.png</Image>',
'</OpenSearchDescription>`;',
'}',
]);
// 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 `<Editor>` is no tag.
write('app/editor/Editor.tsx', [
"import * as React from 'react';",
'export class Editor extends React.Component {',
' render() {',
' return <div />;',
' }',
'}',
]);
write('app/editor/focus.ts', [
"import { Editor } from './Editor';",
'',
'export function focusEditor(view: unknown, closing = "</p>") {',
" if (closing) /'/.test(closing);",
' const editor = <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 = <T,>(items: T[]) => items[0];',
"const slug = (s: string) => s.replace(/[`'\"]/g, '').replace(/\\//g, '-');",
'const note = `${`nested ${first([1])}`} <Fake />`;',
'',
'export function Signup() {',
' return (',
' <form title="<Fake /> is not rendered">',
' <p>Don\'t have an account? <Link to="/signup">Sign up</Link></p>',
' <p>Read https://example.com/docs first: <Docs /></p>',
' <p>Press `Ctrl` and <Kbd>K</Kbd></p>',
' <Field label=\'It"s\' />',
' </form>',
' );',
'}',
]);
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 <Route> in an <Outlet>. */',
'export function Page({ items }) {',
" const re = /<Fake\\/>/g, s = 'a <Fake />', t = `<Fake /> ${items.map((i) => <Item key={i} />)}`;",
' return (',
' <Shell title="<Fake />">',
" {/* <Fake /> */} Don't <Link to=\"/x\">go</Link> to https://a.b/c",
' </Shell>',
' );',
'}',
].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 = <T>(x: T) => T;',
'interface Call { <T>(x: T): T }',
'const first = <T,>(items: T[]) => items[0];',
"const Table = <Entry extends Base>({ rows }) => <Grid rows={rows} label='a \"b' />;",
'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 = <p>a } b</p>;')).toBeNull();
expect(jsxCode('function f() {\n return 1;\n')).toBeNull();
expect(jsxCode("const s = 'open\nconst b = 1;")).toBeNull();
expect(jsxCode('const a = <A><B></A></B>;')).toBeNull();
});
});
+13 -5
View File
@@ -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: `<PaginatedList<Document>
// 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<User>()`,
* a tag in its code, not in a comment, a string or a template (react-router's
* "You cannot render a <Router> inside another <Router>"; `jsxCode`), and
* not only in a type argument or parameter list (`useState<User>()`,
* `<Entry extends BaseEntity>(…) =>`; outline's `<PaginatedList<Document>`
* 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 `"<Foo/>"` inside C as JSX (#1560).
// monorepos must not interpret `"<Foo/>"` inside C as JSX (#1560). A
// TypeScript `.ts` file holds no JSX either: a `<Name>` 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<string, number>(), bare: new Set<string>() };
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;
+519
View File
@@ -0,0 +1,519 @@
/**
* 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 (`{/* <Progress /> *\/}`), an error message ("You cannot
* render a <Router> inside another <Router>"), 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: `<p>Don't have an
* account? <Link to="/signup">`, `<a>https://…</a>`. Read as code, that
* apostrophe opens a string that blanks the `<Link`. So the lexer follows
* elements the way JSX does: one opens where an expression can start, and its
* text runs to a tag or a `{`. A `<` that opens a type parameter list in the
* same place (`<T,>(x: T) =>`, `type Fn = <T>(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;
function skipSpace(src: string, k: number): number {
while (k < src.length && isSpace(src.charCodeAt(k))) k++;
return k;
}
function nameEnd(src: string, k: number): number {
while (k < src.length && isNamePart(src.charCodeAt(k))) k++;
return k;
}
function lineEnd(src: string, k: number): number {
for (let c = src.charCodeAt(k); k < src.length && c !== 10 && c !== 13; c = src.charCodeAt(++k));
return k;
}
/** The `/` closing the regex literal opened at `start`, or -1 when it doesn't close on its line. */
function regexEnd(src: string, start: number): number {
let inClass = false;
for (let k = start + 1; k < src.length; k++) {
const c = src.charCodeAt(k);
if (c === 10 || c === 13) return -1;
if (c === 92) {
k++;
if (src.charCodeAt(k) === 10 || src.charCodeAt(k) === 13) return -1;
} else if (c === 91) inClass = true;
else if (c === 93) inClass = false;
else if (c === 47 && !inClass) return k;
}
return -1;
}
/** The quote closing the string opened at `start`, or -1 when the string doesn't close. */
function stringEnd(src: string, start: number): number {
const quote = src.charCodeAt(start);
for (let k = start + 1; k < src.length; k++) {
const c = src.charCodeAt(k);
if (c === quote) return k;
if (c === 92) k += src.charCodeAt(k + 1) === 13 && src.charCodeAt(k + 2) === 10 ? 2 : 1;
else if (c === 10 || c === 13) return -1;
}
return -1;
}
/** `src` with the ranges (start and end pairs, in order) turned to spaces, line breaks kept. */
function blankRanges(src: string, blanks: number[]): string {
if (blanks.length === 0) return src;
const parts: string[] = [];
let from = 0;
for (let b = 0; b < blanks.length; b += 2) {
const start = blanks[b]!;
const end = blanks[b + 1]!;
if (start < from) continue;
parts.push(src.slice(from, start));
const text = src.slice(start, end);
parts.push(/[\r\n]/.test(text) ? text.replace(/[^\r\n]/g, ' ') : ' '.repeat(end - start));
from = end;
}
parts.push(src.slice(from));
return parts.join('');
}
export function jsxCode(src: string): string | null {
return new JsxLexer(src).read();
}
/**
* The lexer's state for one file. Its steps are methods, not closures made
* per file: a closure made anew for each file is another call target to the
* optimizer, which threw `jsxCode`'s code away and compiled it again for
* file after file, at 40 to 90 ms a time (excalidraw's index did it 15 times).
*/
class JsxLexer {
private readonly n: number;
/** Ranges to blank, as start and end pairs, in source order. */
private readonly blanks: number[] = [];
private readonly stack: Frame[] = [{ mode: 'code', braces: 0, name: '' }];
/** The elements opened among code that are still open, innermost last. */
private readonly roots: Root[] = [];
/** `<`s that failed to read as an element. */
private readonly operators = new Set<number>();
private i = 0;
/** Whether the last token ends an expression: a `<` then compares, a `/` divides. */
private exprEnd = false;
/** Whether the last token is a `.`, so a keyword is a property's name. */
private afterDot = false;
private rereads = 0;
constructor(private readonly src: string) {
this.n = src.length;
}
read(): string | null {
const budget = 4 * this.n + 65536;
let steps = 0;
for (;;) {
while (this.i < this.n) {
if (++steps > budget) return null;
const top = this.stack[this.stack.length - 1]!;
const c = this.src.charCodeAt(this.i);
const ok =
top.mode === 'code' ? this.code(top, c)
: top.mode === 'template' ? this.template(c)
: top.mode === 'tag' ? this.tag(top, c)
: this.children(top, c);
if (!ok) return null;
}
if (this.stack.length === 1 && this.stack[0]!.braces === 0) break;
if (!this.reread()) return null;
}
return blankRanges(this.src, this.blanks);
}
private blank(from: number, to: number): void {
if (to > from) this.blanks.push(from, Math.min(to, this.n));
}
private codeFrame(): void {
this.stack.push({ mode: 'code', braces: 0, name: '' });
this.exprEnd = false;
this.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.
*/
private reread(): boolean {
const root = this.roots.pop();
if (!root || ++this.rereads > MAX_REREADS) return false;
this.stack.length = root.depth;
this.blanks.length = root.blanks;
for (const k of this.operators) if (k > root.at) this.operators.delete(k);
this.operators.add(root.at);
this.i = root.at;
this.exprEnd = false;
this.afterDot = false;
return true;
}
/** Open the element whose `<` is at `lt`. False when it cannot be one. */
private openElement(lt: number): boolean {
const src = this.src;
if (src.charCodeAt(lt + 1) === 62) {
this.stack.push({ mode: 'children', braces: 0, name: '' });
this.i = lt + 2;
return true;
}
let k = nameEnd(src, lt + 1);
const name = src.slice(lt + 1, k);
// `<Entry extends BaseEntity>(…) =>`: a generic arrow function's type parameters.
const next = skipSpace(src, k);
if (next > k && src.startsWith('extends', next) && isSpace(src.charCodeAt(next + 7))) return false;
if (src.charCodeAt(k) === 60) {
// `<Select<Option> …>`: the element's type arguments.
let depth = 0;
const limit = Math.min(this.n, k + 1000);
for (; k < limit; k++) {
const c = src.charCodeAt(k);
if (c === 60) depth++;
else if (c === 62 && --depth === 0) break;
}
if (depth !== 0) return false;
k++;
}
this.stack.push({ mode: 'tag', braces: 0, name });
this.i = k;
return true;
}
/** Close the innermost element. Closing an element opened among code ends an expression. */
private closeElement(): void {
this.stack.pop();
if (this.stack[this.stack.length - 1]!.mode === 'code') {
this.roots.pop();
this.exprEnd = true;
this.afterDot = false;
}
}
/** One step among code. False when the file is given up. */
private code(top: Frame, c: number): boolean {
const src = this.src;
const i = this.i;
if (isSpace(c)) {
this.i = i + 1;
return true;
}
if (c === 47) {
const d = src.charCodeAt(i + 1);
if (d === 47) {
const end = lineEnd(src, i);
this.blank(i, end);
this.i = end;
return true;
}
if (d === 42) {
const end = src.indexOf('*/', i + 2);
if (end === -1) return this.reread();
this.blank(i, end + 2);
this.i = end + 2;
return true;
}
if (this.exprEnd) {
// `/>` among code: an element read as code.
if (d === 62) return this.reread();
this.i = i + 1;
this.exprEnd = false;
this.afterDot = false;
return true;
}
const end = regexEnd(src, i);
if (end === -1) {
this.i = i + 1;
return true;
}
this.blank(i + 1, end);
let k = end + 1;
while (k < this.n && isIdPart(src.charCodeAt(k))) k++;
this.i = k;
this.exprEnd = true;
this.afterDot = false;
return true;
}
if (c === 34 || c === 39) {
const end = stringEnd(src, i);
if (end === -1) return this.reread();
this.blank(i + 1, end);
this.i = end + 1;
this.exprEnd = true;
this.afterDot = false;
return true;
}
if (c === 96) {
this.stack.push({ mode: 'template', braces: 0, name: '' });
this.i = i + 1;
return true;
}
if (c === 60) {
const d = src.charCodeAt(i + 1);
if (!this.exprEnd && (d === 62 || isIdStart(d)) && !this.operators.has(i)) {
this.roots.push({ at: i, depth: this.stack.length, blanks: this.blanks.length });
return this.openElement(i) || this.reread();
}
// `</` among code: a closing tag read as code.
if (d === 47) return this.reread();
this.i = i + (d === 60 || d === 61 ? 2 : 1);
this.exprEnd = false;
this.afterDot = false;
return true;
}
if (c === 123) {
top.braces++;
this.i = i + 1;
this.exprEnd = false;
this.afterDot = false;
return true;
}
if (c === 125) {
if (top.braces > 0) {
top.braces--;
this.i = i + 1;
this.exprEnd = false;
this.afterDot = false;
return true;
}
if (this.stack.length === 1) return this.reread();
// The end of a JSX `{…}` or of a template's `${…}`.
this.stack.pop();
this.i = i + 1;
return true;
}
if (isIdStart(c)) {
let end = i + 1;
while (end < this.n && isIdPart(src.charCodeAt(end))) end++;
this.exprEnd = this.afterDot || !BEFORE_EXPRESSION.has(src.slice(i, end));
this.afterDot = false;
this.i = end;
return true;
}
const d = src.charCodeAt(i + 1);
if ((c >= 48 && c <= 57) || (c === 46 && d >= 48 && d <= 57)) {
let end = i + 1;
for (let e = src.charCodeAt(end); end < this.n && (isIdPart(e) || e === 46); e = src.charCodeAt(++end));
this.i = end;
this.exprEnd = true;
this.afterDot = false;
return true;
}
if (c === 46) {
if (d === 46 && src.charCodeAt(i + 2) === 46) {
this.i = i + 3;
this.exprEnd = false;
this.afterDot = false;
} else {
this.i = i + 1;
this.exprEnd = false;
this.afterDot = true;
}
return true;
}
if (c === 41 || c === 93) {
this.i = i + 1;
this.exprEnd = true;
this.afterDot = false;
return true;
}
// `++` and `--` leave the expression where it was, and so does TypeScript's `x!`.
if ((c === 43 || c === 45) && d === c) {
this.i = i + 2;
return true;
}
if (c === 33 && this.exprEnd && d !== 61) {
this.i = i + 1;
return true;
}
if (c === 35 && i === 0 && d === 33) {
this.i = lineEnd(src, 0);
return true;
}
this.i = i + 1;
this.exprEnd = false;
this.afterDot = false;
return true;
}
/** One step in a template's text. */
private template(c: number): boolean {
const src = this.src;
const i = this.i;
if (c === 96) {
this.stack.pop();
this.i = i + 1;
this.exprEnd = true;
this.afterDot = false;
return true;
}
if (c === 92) {
this.blank(i, i + 2);
this.i = i + 2;
return true;
}
if (c === 36 && src.charCodeAt(i + 1) === 123) {
this.codeFrame();
this.i = i + 2;
return true;
}
let end = i + 1;
for (let e = src.charCodeAt(end); end < this.n && e !== 96 && e !== 92 && e !== 36; e = src.charCodeAt(++end));
this.blank(i, end);
this.i = end;
return true;
}
/** One step in an element's opening tag. False when the file is given up. */
private tag(top: Frame, c: number): boolean {
const src = this.src;
const i = this.i;
if (isSpace(c)) {
this.i = i + 1;
return true;
}
if (c === 62) {
top.mode = 'children';
this.i = i + 1;
return true;
}
if (c === 47) {
const d = src.charCodeAt(i + 1);
if (d === 62) {
this.i = i + 2;
this.closeElement();
return true;
}
if (d === 47) {
const end = lineEnd(src, i);
this.blank(i, end);
this.i = end;
return true;
}
if (d === 42) {
const end = src.indexOf('*/', i + 2);
if (end !== -1) {
this.blank(i, end + 2);
this.i = end + 2;
return true;
}
}
return this.reread();
}
if (c === 123) {
this.codeFrame();
this.i = i + 1;
return true;
}
if (isIdStart(c)) {
const end = nameEnd(src, i + 1);
const eq = skipSpace(src, end);
if (src.charCodeAt(eq) !== 61) {
this.i = end;
return true;
}
const value = skipSpace(src, eq + 1);
const q = src.charCodeAt(value);
if (q === 34 || q === 39) {
// JSX strings have no escapes and may span lines.
const close = src.indexOf(q === 34 ? '"' : "'", value + 1);
if (close === -1) return this.reread();
this.blank(value + 1, close);
this.i = close + 1;
return true;
}
if (q === 123) {
this.codeFrame();
this.i = value + 1;
return true;
}
const next = src.charCodeAt(value + 1);
if (q === 60 && (next === 62 || isIdStart(next)) && this.openElement(value)) return true;
}
return this.reread();
}
/** One step among an element's children. False when the file is given up. */
private children(top: Frame, c: number): boolean {
const src = this.src;
const i = this.i;
if (c === 123) {
this.codeFrame();
this.i = i + 1;
return true;
}
if (c === 60) {
const d = src.charCodeAt(i + 1);
if (d === 47) {
const start = skipSpace(src, i + 2);
const end = nameEnd(src, start);
const gt = skipSpace(src, end);
if (src.charCodeAt(gt) !== 62 || src.slice(start, end) !== top.name) return this.reread();
this.i = gt + 1;
this.closeElement();
return true;
}
if ((d === 62 || isIdStart(d)) && this.openElement(i)) return true;
return this.reread();
}
// JSX text can't hold a `>` or a `}`: code read as text.
if (c === 62 || c === 125) return this.reread();
let end = i + 1;
for (let e = src.charCodeAt(end); end < this.n && e !== 123 && e !== 60 && e !== 62 && e !== 125; e = src.charCodeAt(++end));
this.i = end;
return true;
}
}