From 4a9b299e2e0da39178f06be6728db4c2f6e5fde2 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 01:14:00 -0500 Subject: [PATCH] fix(resolution): a large minified JS bundle no longer stalls resolving on Node 22 go-ethereum's `codegraph init` sat in "Resolving refs" for good on Node 22 (killed after 1 h 55 min and 9,203 CPU-seconds). It was not Go: two resolver pool workers each sat on one 500-ref chunk of graphql/internal/graphiql/graphiql.min.js, a 980 KB bundle on one line (18,388 refs, 2,234 functions). Whether a JS function binds a name itself (jsFunctionLocalScope, #2226) is read off its comment-stripped code with a parameter-list regex that starts with a `(? 38.3 s wall, with resolution 17.7 s -> 12.7 s and the linking passes 6.5 s -> 1.5 s. Tests: js-local-binding-backtracking forces V8's unoptimized mode and indexes a function with a 60,000-character comment before a call (69 s on main, 1.4 s now); jsx-render-work counts the tag scans of 60 components on one line (60 on main, 1 now). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../js-local-binding-backtracking.test.ts | 73 ++++++++++++++++ __tests__/jsx-render-work.test.ts | 84 +++++++++++++++++++ src/resolution/callback-synthesizer.ts | 27 ++++-- src/resolution/name-matcher.ts | 10 ++- 5 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 __tests__/js-local-binding-backtracking.test.ts create mode 100644 __tests__/jsx-render-work.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1144fe4..d43642df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, an exported type written without a package, like `Node` in `func Walk(v Visitor, node Node)`, now links to the type of that name in its own package, whether it is a struct, an interface or another kind of type. Before, it could link to a struct of the same name in an unrelated package, so prometheus's PromQL parser functions pointed at the Kubernetes discovery `Node` struct instead of the parser's own `Node` interface, or, for a result type like `Appender` in `func (f *fanout) Appender(…) Appender`, to the method itself. A type from a package outside your project, like `apiv1.Node` or `http.Handler`, no longer links to a struct of the same name in the file that uses it; a variadic parameter like `...storage.Filter` now links to that package's type; a route handler written as a method value, like `h.Follow` in an Echo or Gin app, links to that method rather than a model struct named `Follow`; and a method called on another call's result or on an indexed value, like `err[i].Error()` or a chained `.String()`, is no longer recorded as creating a struct of the same name. Re-index Go projects after upgrading. - In Go, a function or method passed as a value, like `sync.Pool{New: wm.new}`, now shows the function that passes it among its callers and impact even when it is named `new`, `nil`, `None`, `self` or a handful of similar words, or when a line break or comment sits beside the dot. Before, such a value was skipped, so a method used only that way looked unused. Re-index Go projects after upgrading. - Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334) +- Indexing a project that ships a large minified JavaScript file, such as the GraphiQL bundle inside go-ethereum, no longer stalls at "Resolving refs" when CodeGraph runs on Node 22, and linking such a file is faster on any Node version. The graph it builds is unchanged. - The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334) - In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328) - Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326) diff --git a/__tests__/js-local-binding-backtracking.test.ts b/__tests__/js-local-binding-backtracking.test.ts new file mode 100644 index 000000000..5746cd3e8 --- /dev/null +++ b/__tests__/js-local-binding-backtracking.test.ts @@ -0,0 +1,73 @@ +/** + * Whether a JS function binds a name itself (jsFunctionLocalScope, #2226) is + * read off the function's comment-stripped code with a parameter-list regex. + * Its `(? { + let tmpDir: string | undefined; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.close(); + cg = undefined; + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 }); + tmpDir = undefined; + }); + + const callees = (graph: CodeGraph, name: string): string[] => { + const from = graph.getNodesByName(name).find((n) => n.kind === 'function')!; + return graph.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls') + .map((e) => graph.getNode(e.target)!.name); + }; + + it('stays linear when V8 stops optimizing regexes', async () => { + // Blanked by the comment stripper into one 60,000-character run of spaces. + const comment = `/* ${'word '.repeat(12_000)} */`; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-lookbehind-')); + fs.writeFileSync(path.join(tmpDir, 'paint.js'), [ + 'function paintWidget() {\n return 1;\n}', + // Calls the file's own paintWidget: the check scans all of host's code and finds no binding. + `function host() {\n ${comment}\n return paintWidget();\n}`, + // Calls its parameter, which shadows the file's paintWidget. + `function shadow(paintWidget) {\n ${comment}\n return paintWidget();\n}`, + '', + ].join('\n')); + + v8.setFlagsFromString('--no-regexp-optimization'); + const started = Date.now(); + try { + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + } finally { + v8.setFlagsFromString('--regexp-optimization'); + } + const elapsed = Date.now() - started; + + expect(callees(cg, 'host')).toEqual(['paintWidget']); + expect(callees(cg, 'shadow')).toEqual([]); + // About a second with the fix; minutes when the lookbehind reads back through the run at every position. + expect(elapsed).toBeLessThan(20_000); + }, 300_000); +}); diff --git a/__tests__/jsx-render-work.test.ts b/__tests__/jsx-render-work.test.ts new file mode 100644 index 000000000..68f5ae4b8 --- /dev/null +++ b/__tests__/jsx-render-work.test.ts @@ -0,0 +1,84 @@ +/** + * The jsx-render pass reads each function's own lines for the component tags + * it renders. Every function of a minified bundle spans the bundle's one line, + * so it split, sliced and scanned the whole file once per function: 2,234 + * times over go-ethereum's 980 KB `graphiql.min.js`, 25 of the 28 seconds its + * linking passes took on Node 22 (9 of 13 on Node 24). Functions that span + * the same lines render the same tags, so they share one scan. Counted, never + * timed. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +/** The source of the pass's tag pattern, to count its scans by. */ +const JSX_TAG_SOURCE = String.raw`<([A-Z][A-Za-z0-9_]*)[\s/>]`; + +describe('jsx-render pass work', () => { + let tmpDir: string | undefined; + let cg: CodeGraph | undefined; + const originalExec = RegExp.prototype.exec; + + afterEach(() => { + RegExp.prototype.exec = originalExec; + cg?.close(); + cg = undefined; + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 }); + tmpDir = undefined; + }); + + async function index(files: Record): Promise { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-work-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}'); + for (const [file, content] of Object.entries(files)) fs.writeFileSync(path.join(tmpDir, file), content); + cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + return cg; + } + + /** What each named function renders, by jsx-render edge. */ + const renders = (graph: CodeGraph, name: string): string[] => { + const from = graph.getNodesByName(name).find((n) => n.kind === 'function' || n.kind === 'component')!; + return graph.getOutgoingEdges(from.id) + .filter((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'jsx-render') + .map((e) => graph.getNode(e.target)!.name); + }; + + it('scans a line that many components share once, not once per component', async () => { + const COMPONENTS = 60; + const bundle = Array.from({ length: COMPONENTS }, (_, i) => `function Card${i}(){return }`).join(''); + // A scan of the bundle's line: the tag pattern run over it from the start. + let scans = 0; + RegExp.prototype.exec = function (this: RegExp, input: string) { + if (this.source === JSX_TAG_SOURCE && this.lastIndex === 0 && typeof input === 'string' && + input.includes(`Card${COMPONENTS - 1}(`)) scans++; + return originalExec.call(this, input); + }; + const graph = await index({ + 'bundle.jsx': `${bundle}\n`, + 'badge.jsx': 'export function Badge(){return null}\n', + }); + RegExp.prototype.exec = originalExec; + + for (const i of [0, 31, COMPONENTS - 1]) expect(renders(graph, `Card${i}`)).toEqual(['Badge']); + expect(scans).toBeGreaterThan(0); + expect(scans).toBeLessThan(COMPONENTS / 4); + }); + + it('keeps the tags of functions on different lines apart', async () => { + const graph = await index({ + 'page.jsx': [ + 'export function Page() { return
; }', + 'export function Footer() {', + ' return ;', + '}', + 'function Header(){return null} function Links(){return null}', + '', + ].join('\n'), + }); + expect(renders(graph, 'Page')).toEqual(['Header']); + expect(renders(graph, 'Footer')).toEqual(['Links']); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ebecb41a8..da549b5c7 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1432,13 +1432,28 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): let imports: Map | null = null; const importsOf = () => (imports ??= importedFrom(ctx, file, parents[0]!.language)); + // A function renders the tags its own lines hold, so functions that span + // 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. + let lines: string[] | null = null; + const tagsBySpan = new Map>(); for (const parent of parents) { - const src = sliceLines(content, parent.startLine, parent.endLine); - if (!src || (!src.includes(''))) continue; - const names = new Set(); - JSX_TAG_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!); + if (!parent.startLine || !parent.endLine) continue; + const span = `${parent.startLine}:${parent.endLine}`; + let names = tagsBySpan.get(span); + if (!names) { + names = new Set(); + lines ??= content.split('\n'); + const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n'); + if (src.includes('')) { + JSX_TAG_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!); + } + tagsBySpan.set(span, names); + } let added = 0; for (const name of names) { if (added >= MAX_JSX_CHILDREN) break; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 08dc5575f..14c2949db 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6810,7 +6810,15 @@ function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: Re const declared = new RegExp(`\\b(?:const|let|var)\\s+${n}\\b(?!\\s*[,\\]}])`).test(text); // A parameter list — never a control-flow head (`if (openMarkerClose) {`). // A return type stays on its line, never a ternary's `: data.slice()` below `filter(canRowExpand)`. - const parameter = new RegExp(`(?