fix(js,ts): a receiver declared outside the calling function goes by its declared type (#2811)

react-dom's root.render(<X />) linked to the project's one class
component render() when root was declared outside the calling
function, a module's let root: Root | null or a let popupRoot: Root in
the function around the one rendering, or held what createRoot(el)
returned (#2589). The receiver reader scans only the calling function,
so the type that rules a react-dom Root out was never read, and the
call went to the one same-named method by its name.

A TS/JS receiver the calling function doesn't type, in a function
nested in another or one a module-scope variable names, is now read by
its binding in scope at the call. A per-file brace-scope reader finds
the innermost binding of the name: a const/let/var, a parameter, a
destructuring, a for head, a function or class, a lone or destructured
arrow parameter. A declaration or parameter written with a type, or a
const/let/var whose whole value is a new of a class, types the call as
the calling function's own declaration would; an annotated object
literal stays its own members', and a new of a parameter names no
class. A class component's render() and lifecycle methods are never a
by-name guess for a receiver other than this or super, since React
calls them, which also covers an untyped const r = createRoot(el). A
first version that also took any value a package function returned to
have no project method was dropped: it unlinked correct calls through
useContext(...) values in social-app and outline.

Validation: the new test fails 3 of 3 on main 89cd08fa and passes on
the fix. A/B against main on 14 TS/JS repos: 35 edges removed,
every one a wrong guess on a value whose type outside the calling
function comes from outside the project (rolldown's watcher, node's
Server and ChildProcess, playwright, unstorage, zod, NodeJS.WriteStream,
HTMLElement) or nitro's ReactDOM.createRoot root; 18 added and
7 retargeted, all precise (memfs's onAbort watcher.close() now
reaches FsaNodeFsWatcher::close, jest's currentSpec.cancel() Spec's);
92 same-target edges typed at 0.9 instead of guessed. The flask and
gin control dumps are byte-identical. Index time, median of 8
interleaved runs on vite, nuxt, webdriverio and insomnia: +1.7% total,
within noise (-3.6 to +5.2% per repo). A CLI sync that retypes a
module binding and adds files matches a fresh index.

Fixes #2589

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-22 18:11:37 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f3fdba2955
commit 08478eea53
3 changed files with 576 additions and 10 deletions
+1
View File
@@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
- In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588)
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
- In JavaScript and TypeScript, react-dom's `root.render(<Popup />)` no longer links to your own class component's `render()`, such as an error boundary's, when `root` is declared outside the function making the call, like a module's `let root: Root | null`, or holds what `createRoot(el)` returned. A call on a variable declared outside the calling function now goes by the type that declaration gives it, so one declared with your own class, like `const store = new Store()` at the top of a file, links to that class's method, and a class component's `render()` and lifecycle methods, which only React calls, are no longer a guess for an untyped value. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report. (#2589)
- In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600)
- In Python, `from app import email` no longer links the importing file to a project variable named `app`, such as a FastAPI or Flask `app = FastAPI()`. (#2584) - In Python, `from app import email` no longer links the importing file to a project variable named `app`, such as a FastAPI or Flask `app = FastAPI()`. (#2584)
- In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582) - In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582)
+142
View File
@@ -0,0 +1,142 @@
/**
* react-dom's `root.render(<X />)` linked to the project's one class-component
* `render()` (#2589) when `root` was declared outside the calling function — a
* module's `let root: Root | null`, a `let popupRoot: Root` in the function
* around the one rendering — or held what `createRoot(el)` returned. A JS/TS
* receiver the calling function doesn't type is now read by its binding in
* scope at the call, as the function's own declaration is: the type `Root`
* says the method is react-dom's, and a project type the binding is written
* with or makes with `new` is the call's type. A nearer binding of the name —
* a parameter, an arrow's or a destructured one — still decides, and another
* function's binding holds in that function only. Nor is a class component's
* `render()` or lifecycle method a guess for a receiver nothing types: React
* calls those.
*/
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';
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-receiver-scope-'));
roots.push(root);
for (const [rel, content] of Object.entries(files)) {
const file = path.join(root, rel);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}
/** `caller -> Owner::method` for every `calls` edge out of `file` that lands on a method. */
function methodCalls(graph: CodeGraph, file: string): string[] {
const out: string[] = [];
for (const node of graph.getNodesInFile(file)) {
for (const e of graph.getOutgoingEdges(node.id)) {
if (e.kind !== 'calls') continue;
const target = graph.getNode(e.target)!;
if (target.kind === 'method') out.push(`${node.name} -> ${target.qualifiedName}`);
}
}
return out.sort();
}
const BOUNDARY = 'import { Component } from "react";\nexport class Boundary extends Component<{ children?: React.ReactNode }> {\n render() { return this.props.children; }\n}\n';
describe('a receiver is read by its binding in scope at the call', () => {
it("links react-dom's root to no project render, however it was declared (#2589)", async () => {
const graph = await index({
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18.0.0', 'react-dom': '^18.0.0' } }),
'src/Boundary.tsx': BOUNDARY,
// The issue's reproduction.
'src/popup.tsx':
'import { createRoot, type Root } from "react-dom/client";\nlet root: Root | null = null;\n' +
'export function showPopup(el: HTMLElement) {\n if (!root) root = createRoot(el);\n root.render(<div />);\n}\n',
'src/popup2.tsx':
'import { createRoot } from "react-dom/client";\nexport function mount(el: HTMLElement) {\n const r = createRoot(el);\n r.render(<div />);\n}\n',
'src/popup3.tsx':
'import { createRoot, type Root } from "react-dom/client";\nexport function mount3(el: HTMLElement) {\n const r: Root = createRoot(el);\n r.render(<div />);\n}\n' +
'export function mount4(root: Root) {\n root.render(<div />);\n}\n',
'src/more.tsx':
'import { createRoot, type Root } from "react-dom/client";\nlet modRoot: Root;\n' +
'export function a1(el: HTMLElement) { modRoot = createRoot(el); modRoot.render(<div />); }\n\n' +
'export function outer(el: HTMLElement) {\n let popupRoot: Root | null = null;\n function renderPopup() { popupRoot!.render(<div />); }\n' +
' popupRoot = createRoot(el);\n return renderPopup;\n}\n',
// Through the default export, at module scope and in a function, and in JavaScript.
'src/main.tsx':
'import ReactDOM from "react-dom/client";\nconst el = document.getElementById("root")!;\n' +
'if (!el.innerHTML) {\n const root = ReactDOM.createRoot(el);\n root.render(<div />);\n}\n' +
'const shared = ReactDOM.hydrateRoot(el, <div />);\nexport function rerender() {\n shared.render(<div />);\n}\n',
'src/Card.jsx': 'import { Component } from "react";\nexport class Card extends Component {\n render() { return null; }\n}\n',
'src/legacy.jsx':
'import { createRoot } from "react-dom/client";\nexport function boot(el) {\n const root = createRoot(el);\n root.render(<div />);\n}\n',
});
for (const file of ['src/popup.tsx', 'src/popup2.tsx', 'src/popup3.tsx', 'src/more.tsx', 'src/main.tsx', 'src/legacy.jsx']) {
expect(methodCalls(graph, file), file).toEqual([]);
}
}, 120_000);
it("takes a project type from a binding outside the calling function", async () => {
const graph = await index({
'package.json': JSON.stringify({ name: 'app' }),
'src/queues.ts':
'export class Store {\n dispatch(action: string) { return action; }\n}\n' +
'export class Queue {\n dispatch(job: string) { return job; }\n}\n',
'src/use.ts':
'import { Store, Queue } from "./queues";\n' +
// A module's binding, named after neither class.
'const s = new Store();\nlet q: Queue | undefined;\n' +
'export function send() { s.dispatch("a"); }\nexport function drain() { q!.dispatch("b"); }\n' +
// The function around the one calling, and its parameter.
'export function outer() {\n const pending = new Queue();\n function flush() { pending.dispatch("c"); }\n return flush;\n}\n' +
'export function around(svc: Store) {\n function go() { svc.dispatch("d"); }\n return go;\n}\n',
});
expect(methodCalls(graph, 'src/use.ts')).toEqual([
'drain -> Queue::dispatch',
'flush -> Queue::dispatch',
'go -> Store::dispatch',
'send -> Store::dispatch',
]);
}, 120_000);
it('lets a nearer binding of the name decide', async () => {
const graph = await index({
'package.json': JSON.stringify({ name: 'app', dependencies: { 'react-dom': '^18.0.0' } }),
// A method a call is guessed to is one of its own language's.
'src/chart.tsx': 'export class Chart {\n render() { return 1; }\n}\n',
'src/gauge.jsx': 'export class Gauge {\n close() { return 1; }\n}\n',
'src/views.tsx':
'import { createRoot, type Root } from "react-dom/client";\nimport { Chart } from "./chart";\n' +
'let root: Root | null = null;\nexport function init(el: HTMLElement) { root = createRoot(el); }\n' +
// A typed parameter, an arrow's own parameter, a destructured one.
'export function typed(root: Chart) { root.render(); }\n' +
'export function each(items: Chart[]) { items.forEach((root) => root.render()); }\n' +
'export function Panel({ root }: { root: any }) { return root.render(); }\n' +
// Another function's own declaration holds in that function only.
'export function other() { const root = new Chart(); return root; }\n' +
'export function show() { root!.render(<div />); }\n',
// A `new` of a parameter names no class to look the method up on.
'src/later.jsx':
'export function make(Backend) {\n const watcher = new Backend();\n function stop() { watcher.close(); }\n return stop;\n}\n',
});
expect(methodCalls(graph, 'src/views.tsx')).toEqual([
'Panel -> Chart::render',
'each -> Chart::render',
'typed -> Chart::render',
]);
expect(methodCalls(graph, 'src/later.jsx')).toEqual(['stop -> Gauge::close']);
}, 120_000);
});
+433 -10
View File
@@ -11632,15 +11632,7 @@ function enclosingOpenParen(source: string, at: number): number {
* arrow. * arrow.
*/ */
function bindsAtSites(source: string, name: string, sites: LocalBindingSites): boolean { function bindsAtSites(source: string, name: string, sites: LocalBindingSites): boolean {
let patterns = LOCAL_BINDING_PATTERNS.get(name); const { decl, fn, param } = stickyBindingPatterns(name);
if (!patterns) {
patterns = localBindingPatterns(name, 'y');
if (LOCAL_BINDING_PATTERNS.size >= LOCAL_BINDING_PATTERNS_CAP) {
LOCAL_BINDING_PATTERNS.delete(LOCAL_BINDING_PATTERNS.keys().next().value!);
}
LOCAL_BINDING_PATTERNS.set(name, patterns);
}
const { decl, fn, param } = patterns;
let from = 0; let from = 0;
for (const at of sites.varDecls) { for (const at of sites.varDecls) {
if (at < from) continue; if (at < from) continue;
@@ -17848,6 +17840,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
JS_FN_LOCAL_MEMO.delete(context); JS_FN_LOCAL_MEMO.delete(context);
JS_CODE_BINDS.delete(context); JS_CODE_BINDS.delete(context);
JS_FN_CODE.delete(context); JS_FN_CODE.delete(context);
JS_SCOPE_FILES.delete(context);
JS_MODULE_VALUES.delete(context);
REACT_LIFECYCLE_MEMO.delete(context);
TS_FIELD_DECL_MEMO.delete(context); TS_FIELD_DECL_MEMO.delete(context);
TS_CLASS_LINES.delete(context); TS_CLASS_LINES.delete(context);
TS_PASS_THROUGH_WRAPPERS.delete(context); TS_PASS_THROUGH_WRAPPERS.delete(context);
@@ -17875,6 +17870,13 @@ function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
return patterns; return patterns;
} }
/**
* The type a TS annotation writes, captured: `Logger`, `Repository<User>` (as
* `Repository`), and with brackets after it whole — `Item[]`, `Element["ids"]`
* — for matchMethodCall to read (arrayElementType, tsIndexedPropertyType).
*/
const TS_ANNOTATED_TYPE = '([A-Z][\\w.$]*(?:(?:\\s*<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*\\[[^\\]]*\\])?)';
export function localReceiverTypePatterns(language: Language, r: string): RegExp[] { export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r)); return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r));
} }
@@ -17908,7 +17910,7 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
// can tell an array of it (`items: Item[]`, `items: Item<K>[]`), whose // can tell an array of it (`items: Item[]`, `items: Item<K>[]`), whose
// own methods aren't an element's (arrayElementType), from one of its // own methods aren't an element's (arrayElementType), from one of its
// properties (`groupIds: Element["groupIds"]`), which isn't it. // properties (`groupIds: Element["groupIds"]`), which isn't it.
new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*(?:(?:\\s*<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\\s*\\[[^\\]]*\\])?)`), // lg: Logger (annotation or typed param) new RegExp(`\\b${r}\\b\\s*:\\s*${TS_ANNOTATED_TYPE}`), // lg: Logger (annotation or typed param)
]; ];
case 'python': case 'python':
return [ return [
@@ -18839,6 +18841,373 @@ function resolveAwaitedCallType(
return { name: typeNode.name, filePath: typeNode.filePath }; return { name: typeNode.name, filePath: typeNode.filePath };
} }
/**
* A JS/TS file's code read for the bindings its names have (jsBindingAt):
* comments, strings, template literals and regex literals blanked; where each
* line starts; its braces as a tree of scopes, the first the file's own; and
* where a `const`/`let`/`var`, a `function`/`class` or an arrow can start.
* Per name, the bindings jsBindingSites finds.
*/
interface JsScopeFile {
code: string;
lineStarts: number[];
scopes: { start: number; end: number; parent: number }[];
varDecls: number[];
fnDecls: number[];
arrows: number[];
bindings: Map<string, JsBindingSite[]>;
}
/**
* A binding of a name: where it holds (`from`, its block's `{` or its body's
* start, through `to`) and where it is written (`at`). A `const`/`let`/`var`
* of the name alone is a declaration, a parameter list's name a parameter,
* a `function` or `class` declaration a function. Any other binding — a
* destructuring, a `for` head, a lone or destructured arrow parameter — says
* nothing of the value. `reading` keeps what the binding says
* (jsBindingReading).
*/
interface JsBindingSite {
from: number;
to: number;
at: number;
kind: 'declaration' | 'parameter' | 'function' | 'other';
reading?: JsBindingReading | null;
}
/**
* What a binding says its value is: an instance of the type it is written
* with, or of the class its whole value makes with `new` (`raw` as written,
* on `line`).
*/
type JsBindingReading = { type: string; raw: string; line: number };
/** Per context, the files jsScopeFile read last, most recent last. */
const JS_SCOPE_FILES = new WeakMap<ResolutionContext, Map<string, JsScopeFile | null>>();
const JS_SCOPE_FILES_KEEP = 16;
function jsScopeFile(filePath: string, context: ResolutionContext): JsScopeFile | null {
let files = JS_SCOPE_FILES.get(context);
if (!files) JS_SCOPE_FILES.set(context, (files = new Map()));
let file = files.get(filePath);
if (file !== undefined) {
files.delete(filePath);
} else {
file = null;
const source = context.readFile(filePath);
// A bundle's names are nothing a person declares a receiver with.
if (source !== null && source.length <= 1_000_000 && !isMinifiedContent(filePath, source)) {
const regexes: number[] = [];
const chars = blankStringContents(stripCommentsForRegex(source, 'javascript'), regexes).split('');
for (let r = 0; r + 1 < regexes.length; r += 2) chars.fill(' ', regexes[r]! + 1, regexes[r + 1]!);
const code = chars.join('');
const lineStarts = [0];
const scopes = [{ start: -1, end: code.length, parent: -1 }];
const open = [0];
for (let i = 0; i < code.length; i++) {
const c = code[i];
if (c === '\n') lineStarts.push(i + 1);
else if (c === '{') {
scopes.push({ start: i, end: code.length, parent: open[open.length - 1]! });
open.push(scopes.length - 1);
} else if (c === '}' && open.length > 1) scopes[open.pop()!]!.end = i;
}
const offsets = (re: RegExp): number[] => Array.from(code.matchAll(re), (m) => m.index!);
const arrows: number[] = [];
for (let a = code.indexOf('=>'); a !== -1; a = code.indexOf('=>', a + 2)) arrows.push(a);
file = { code, lineStarts, scopes, varDecls: offsets(VAR_DECL_SITE), fnDecls: offsets(FN_DECL_SITE), arrows, bindings: new Map() };
}
if (files.size >= JS_SCOPE_FILES_KEEP) files.delete(files.keys().next().value!);
}
files.set(filePath, file);
return file;
}
/** The innermost scope of `file` that holds `offset`. */
function jsScopeAt(file: JsScopeFile, offset: number): number {
const { scopes } = file;
let lo = 0, hi = scopes.length;
while (lo + 1 < hi) {
const mid = (lo + hi) >>> 1;
if (scopes[mid]!.start < offset) lo = mid; else hi = mid;
}
while (lo > 0 && scopes[lo]!.end < offset) lo = scopes[lo]!.parent;
return lo;
}
/** localBindingPatterns, sticky, by name. */
function stickyBindingPatterns(name: string): LocalBindingPatterns {
let patterns = LOCAL_BINDING_PATTERNS.get(name);
if (!patterns) {
patterns = localBindingPatterns(name, 'y');
if (LOCAL_BINDING_PATTERNS.size >= LOCAL_BINDING_PATTERNS_CAP) {
LOCAL_BINDING_PATTERNS.delete(LOCAL_BINDING_PATTERNS.keys().next().value!);
}
LOCAL_BINDING_PATTERNS.set(name, patterns);
}
return patterns;
}
/** Every binding of `name` in the file, read once per file and name. */
function jsBindingSites(name: string, file: JsScopeFile): JsBindingSite[] {
const known = file.bindings.get(name);
if (known) return known;
const sites: JsBindingSite[] = [];
const { code } = file;
const { decl, fn, param } = stickyBindingPatterns(name);
const blockOf = (at: number) => {
const scope = file.scopes[jsScopeAt(file, at)]!;
return { from: scope.start, to: scope.end };
};
let resume = 0;
for (const at of file.varDecls) {
if (at < resume) continue;
decl.lastIndex = at;
const m = decl.exec(code);
if (!m) continue;
resume = at + m[0].length;
if (/\bfor\s*(?:await\s*)?\(\s*$/.test(code.slice(Math.max(0, at - 16), at))) {
// A `for` head's binding holds in the head and the statement after it.
sites.push({ from: code.lastIndexOf('(', at), to: jsForStatementEnd(code, at), at, kind: 'other' });
continue;
}
// `const x = require('./x')` binds an import, which the file's imports answer for.
const plain = !/^(?:const|let|var)\s+[{[]/.test(m[0]) && !IMPORT_BINDING_VALUE.test(m[1] ?? '');
sites.push({ ...blockOf(at), at, kind: plain ? 'declaration' : 'other' });
}
for (const at of file.fnDecls) {
fn.lastIndex = at;
if (fn.test(code)) sites.push({ ...blockOf(at), at, kind: 'function' });
}
const n = escapeRegExp(name);
const paramName = new RegExp(`(?:^\\(|,)\\s*${PARAM_MODIFIERS}(?:\\.\\.\\.)?(${n})(?![\\w$])`);
let tried = -1;
for (let at = code.indexOf(name); at !== -1; at = code.indexOf(name, at + 1)) {
// A whole name, and no member's (`el.root`), though a rest parameter's (`...root`) is one.
if ((at > 0 && /[\w$]/.test(code[at - 1]!)) || /[\w$]/.test(code[at + name.length] ?? '') ||
(code[at - 1] === '.' && code.slice(Math.max(0, at - 3), at) !== '...')) continue;
const open = enclosingOpenParen(code, at);
// A control-flow head, `if (root) {`, binds nothing.
if (open >= 0 && /\b(?:if|while|for|switch|with)\s*$/.test(code.slice(Math.max(0, open - 12), open))) continue;
if (open < 0) {
// A destructured parameter: `({ root }) => …`, `function f([root]) {`.
const body = destructuredParameterBody(code, at);
if (body) sites.push({ ...body, at, kind: 'other' });
continue;
}
if (open === tried) continue;
tried = open;
param.lastIndex = open;
const m = param.exec(code);
if (!m) continue;
const end = open + m[0].length;
const own = paramName.exec(m[0]);
sites.push({
...(code[end - 1] === '{' ? { from: end - 1, to: jsBlockEnd(code, end) } : { from: end, to: jsBodyEnd(code, end) }),
at: own ? open + own.index + own[0].length - name.length : at,
kind: 'parameter',
});
}
// `name =>`: the name ends where the whitespace before the arrow starts.
for (const arrow of file.arrows) {
let end = arrow;
while (end > 0 && WHITESPACE.test(code[end - 1]!)) end--;
const start = end - name.length;
if (start >= 0 && code.startsWith(name, start) && (start === 0 || !ARROW_HEAD_CHAR.test(code[start - 1]!))) {
sites.push({ from: arrow + 2, to: jsBodyEnd(code, arrow + 2), at: start, kind: 'other' });
}
}
file.bindings.set(name, sites);
return sites;
}
/** Where a `for` statement whose head holds `at` ends: past the statement after its head. */
function jsForStatementEnd(code: string, at: number): number {
let depth = 0;
for (let i = at; i < code.length; i++) {
const c = code[i];
if (c === '(' || c === '[' || c === '{') depth++;
else if ((c === ')' || c === ']' || c === '}') && --depth < 0) return c === ')' ? jsBodyEnd(code, i + 1) : i;
}
return code.length;
}
/**
* The body of the function whose parameter list destructures the name at
* `at` (`({ a, b: [root] }) => …`), or null when the name is in no
* parameter list: read back out of the braces and brackets around it to the
* `(` that opens the list, which a `=>` or a body's `{` follows (past a
* return type) and no `if`, `while`, `for`, `switch` or `with` heads.
*/
function destructuredParameterBody(code: string, at: number): { from: number; to: number } | null {
let depth = 0;
let nested = false;
let open = -1;
for (let i = at - 1; i >= 0 && at - i <= 2000; i--) {
const c = code[i];
if (c === ')' || c === ']' || c === '}') depth++;
else if (c === '(' || c === '[' || c === '{') {
if (depth > 0) {
depth--;
} else if (c === '(') {
open = i;
break;
} else {
nested = true;
}
} else if (c === ';' && depth === 0) {
return null;
}
}
if (!nested || open < 0 || /\b(?:if|while|for|switch|with)\s*$/.test(code.slice(Math.max(0, open - 12), open))) return null;
let close = open + 1;
for (let d = 1; close < code.length && d > 0; close++) {
if (code[close] === '(') d++;
else if (code[close] === ')') d--;
}
const head = /^\s*(?::(?:[^=;{}()\n]|\{(?:[^{}]|\{[^{}]*\})*\})*)?(=>|\{)/.exec(code.slice(close, close + 2000));
if (!head) return null;
const end = close + head[0].length;
return head[1] === '{' ? { from: end - 1, to: jsBlockEnd(code, end) } : { from: end, to: jsBodyEnd(code, end) };
}
/**
* The binding of `name` in scope at `offset` of a JS/TS file: the one whose
* scope holds it innermost, and of two in one scope the declaration nearest
* before it. Null when no binding of the name holds there.
*/
function jsBindingAt(name: string, file: JsScopeFile, offset: number): JsBindingSite | null {
let best: JsBindingSite | null = null;
for (const site of jsBindingSites(name, file)) {
if (site.from > offset || site.to < offset) continue;
if (!best || site.from > best.from ||
(site.from === best.from && (site.at <= offset ? best.at > offset || site.at > best.at : best.at > offset && site.at < best.at))) {
best = site;
}
}
return best;
}
/**
* What a binding of `name` says its value is (JsBindingReading), or null when
* it says nothing: a declaration or a parameter written with a type, or a
* declaration whose whole value is a `new` of a class.
*/
function jsBindingReading(name: string, site: JsBindingSite, file: JsScopeFile): JsBindingReading | null {
if (site.reading === undefined) {
site.reading = site.kind === 'parameter' ? jsParameterReading(name, site, file)
: site.kind === 'declaration' ? jsDeclarationReading(name, site, file)
: null;
}
return site.reading;
}
/** The 1-based line of an offset in `file`. */
function jsLineOf(file: JsScopeFile, offset: number): number {
let lo = 0, hi = file.lineStarts.length;
while (lo + 1 < hi) {
const mid = (lo + hi) >>> 1;
if (file.lineStarts[mid]! <= offset) lo = mid; else hi = mid;
}
return lo + 1;
}
/** The type a parameter is written with (`root: Root`, `svc?: Store`). */
function jsParameterReading(name: string, site: JsBindingSite, file: JsScopeFile): JsBindingReading | null {
const typed = new RegExp(`${escapeRegExp(name)}(?![\\w$])\\s*\\??\\s*:\\s*${TS_ANNOTATED_TYPE}`, 'y');
typed.lastIndex = site.at;
const raw = typed.exec(file.code)?.[1];
const type = raw ? normalizeInferredTypeName(raw) : null;
return raw && type ? { type, raw, line: jsLineOf(file, site.at) } : null;
}
/**
* What a `const`/`let`/`var` says its value is: the type it is written with,
* or the class its whole value makes with `new` — not a `new` of a
* parameter or variable (`new Backend(root)`), which names no class.
*/
function jsDeclarationReading(name: string, site: JsBindingSite, file: JsScopeFile): JsBindingReading | null {
const { code } = file;
const head = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(name)}(?![\\w$])\\s*(?:!?\\s*:\\s*${TS_ANNOTATED_TYPE})?`, 'y');
head.lastIndex = site.at;
const m = head.exec(code);
if (!m) return null;
const valueAt = site.at + m[0].length;
const rest = code.slice(valueAt, valueAt + 400);
// An object literal is its own members' (resolveObjectPathMember), whatever
// type it is written with: vite's `const server: ViteDevServer = { … }` is
// the `close` it declares, not the interface's.
if (/^[^=;]*=(?![=>])\s*\{/.test(rest)) return null;
const line = jsLineOf(file, site.at);
if (m[1]) {
const type = normalizeInferredTypeName(m[1]);
return type ? { type, raw: m[1], line } : null;
}
const made = /^\s*=\s*new\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*(?:<[^;(){}]*>\s*)?\(/.exec(rest);
if (!made) return null;
const className = made[1]!.replace(/\s+/g, '');
const bound = jsBindingAt(className.split('.')[0]!, file, valueAt);
if (bound && bound.kind !== 'function') return null;
// The `new` is the whole value: no member, index, call or operator follows it.
let close = valueAt + made[0].length;
let depth = 1;
for (const limit = close + 5000; close < code.length && close < limit && depth > 0; close++) {
if (code[close] === '(') depth++;
else if (code[close] === ')') depth--;
}
if (depth > 0 || !/^[ \t]*(?:[;,)}]|\r?\n(?![ \t]*(?:[.(\[?:]|\?\?|\|\||&&|as\b))|$)/.test(code.slice(close, close + 200))) return null;
const type = normalizeInferredTypeName(className);
return type ? { type, raw: className, line } : null;
}
/**
* Per context and file: how many functions and methods hold each line, and
* the names the file's module scope gives a variable or constant node.
*/
const JS_MODULE_VALUES = new WeakMap<ResolutionContext, Map<string, { depth: Int32Array; names: Set<string> }>>();
/**
* Whether the binding a call's receiver has may lie outside the function
* making the call, where the calling function's own declarations, which the
* receiver reader has read already, can't say what it is: in a function
* around that one, or at module scope, which declares the name. Code outside
* any function was read from the file's start.
*/
function jsMayBindOutside(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
let files = JS_MODULE_VALUES.get(context);
if (!files) JS_MODULE_VALUES.set(context, (files = new Map()));
let scope = files.get(ref.filePath);
if (!scope) {
const nodes = context.getNodesInFile(ref.filePath);
const functions = nodes.filter((n) => n.kind === 'function' || n.kind === 'method');
const depth = new Int32Array(functions.reduce((last, n) => Math.max(last, n.endLine), 0) + 2);
for (const n of functions) {
depth[n.startLine]!++;
depth[n.endLine + 1]!--;
}
for (let line = 1; line < depth.length; line++) depth[line]! += depth[line - 1]!;
const names = new Set(nodes.filter((n) => (n.kind === 'variable' || n.kind === 'constant') && n.qualifiedName === n.name).map((n) => n.name));
if (files.size >= 64) files.delete(files.keys().next().value!);
files.set(ref.filePath, (scope = { depth, names }));
}
const around = scope.depth[ref.line] ?? 0;
return around > 1 || (around === 1 && scope.names.has(name));
}
/**
* A JS/TS receiver the calling function doesn't type, read by its binding in
* scope at the call (#2589): a module's `let root: Root | null`, a `let
* popupRoot: Root` in the function around the one calling, or an outer
* function's typed parameter. Null when the binding says nothing of its value.
*/
function jsReceiverBindingReading(receiver: string, ref: UnresolvedRef, context: ResolutionContext): JsBindingReading | null {
if (!jsMayBindOutside(receiver, ref, context)) return null;
const file = jsScopeFile(ref.filePath, context);
if (!file) return null;
const site = jsBindingAt(receiver, file, (file.lineStarts[ref.line - 1] ?? file.code.length) + ref.column);
return site ? jsBindingReading(receiver, site, file) : null;
}
/** /**
* Patterns that recover a PHP class property's declared type for a * Patterns that recover a PHP class property's declared type for a
* `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only * `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
@@ -19192,6 +19561,22 @@ export function matchMethodCall(
if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null; if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null;
inferredType = awaited.name; inferredType = awaited.name;
} }
// A TS/JS receiver the calling function doesn't type is read by its
// binding in scope at the call (#2589): a module's `let root: Root |
// null`, a `let popupRoot: Root` in the function around the one calling,
// or an outer function's typed parameter types it, as the calling
// function's own declaration would.
if (!inferredType && !awaited && dotMatch && /^[A-Za-z_$][\w$]*$/.test(objectOrClass!) &&
(ref.language === 'typescript' || ref.language === 'tsx' || ref.language === 'javascript' || ref.language === 'jsx') &&
objectOrClass !== 'this' && objectOrClass !== 'super' && !isImportBinding(objectOrClass!, ref, context) &&
isLocallyBoundJsName(objectOrClass!, ref.filePath, context)) {
const reading = nmTimedT('mc-jsbinding', ref, () => jsReceiverBindingReading(objectOrClass!, ref, context));
if (reading) {
inferredType = reading.type;
decl.raw = reading.raw;
decl.line = reading.line;
}
}
// A TS annotation with brackets after the type. An array of it (`items: // A TS annotation with brackets after the type. An array of it (`items:
// Item[]`): a method arrays have is the array's, which no project class // Item[]`): a method arrays have is the array's, which no project class
// declares, where `distinctElements.find(…)` on vscode's `ExplorerItem[]` // declares, where `distinctElements.find(…)` on vscode's `ExplorerItem[]`
@@ -19731,6 +20116,14 @@ export function matchMethodCall(
narrowed ||= kept.length !== targetMethods.length; narrowed ||= kept.length !== targetMethods.length;
targetMethods = kept; targetMethods = kept;
} }
// A React class component's `render()` and lifecycle methods are React's
// to call (#2589): react-dom's `root.render(<App />)` is no error
// boundary's `render`, whatever holds the root.
if (JS_FAMILY.has(ref.language) && objectOrClass !== 'this' && objectOrClass !== 'super') {
const kept = targetMethods.filter((m) => !isReactLifecycleMethod(m, context));
narrowed ||= kept.length !== targetMethods.length;
targetMethods = kept;
}
// A python attribute chain nothing typed (matchPythonAttrCall) is guessed // A python attribute chain nothing typed (matchPythonAttrCall) is guessed
// only by the name of its last link, in the scoring below — Django's // only by the name of its last link, in the scoring below — Django's
@@ -19871,6 +20264,36 @@ function isImportedModuleReceiver(receiver: string, ref: UnresolvedRef, context:
return binding.isNamespace || context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; return binding.isNamespace || context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true;
} }
/** The methods React calls on a class component itself. */
const REACT_LIFECYCLE_METHODS: ReadonlySet<string> = new Set([
'render', 'componentDidMount', 'componentDidUpdate', 'componentWillUnmount', 'shouldComponentUpdate',
'getSnapshotBeforeUpdate', 'componentDidCatch', 'componentWillMount', 'componentWillReceiveProps',
'componentWillUpdate', 'UNSAFE_componentWillMount', 'UNSAFE_componentWillReceiveProps', 'UNSAFE_componentWillUpdate',
]);
const REACT_LIFECYCLE_MEMO = new WeakMap<ResolutionContext, Map<string, boolean>>();
/**
* A lifecycle method (`render`, `componentDidMount`, …) of a class that
* extends React's `Component` or `PureComponent` (or Preact's), read from the
* class's head.
*/
function isReactLifecycleMethod(n: Node, context: ResolutionContext): boolean {
if (n.kind !== 'method' || !REACT_LIFECYCLE_METHODS.has(n.name)) return false;
let memo = REACT_LIFECYCLE_MEMO.get(context);
if (!memo) REACT_LIFECYCLE_MEMO.set(context, (memo = new Map()));
let hit = memo.get(n.id);
if (hit === undefined) {
const ownerName = n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split('::').pop();
const owner = ownerName ? context.getNodesInFile(n.filePath).find((c) =>
c.kind === 'class' && c.name === ownerName && c.startLine <= n.startLine && c.endLine >= n.endLine) : undefined;
const lines = owner ? context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? [] : [];
const head = owner ? lines.slice(owner.startLine - 1, Math.min(owner.startLine + 3, n.startLine)).join(' ').split('{')[0]! : '';
hit = /\bextends\s+(?:React\s*\.\s*)?(?:Pure)?Component\b/.test(head);
memo.set(n.id, hit);
}
return hit;
}
/** A method a Vue Options API component declares for itself (`index::handleLogin` in `index.vue`). */ /** A method a Vue Options API component declares for itself (`index::handleLogin` in `index.vue`). */
function isVueComponentMethod(n: Node): boolean { function isVueComponentMethod(n: Node): boolean {
return n.kind === 'method' && n.filePath.endsWith('.vue') && n.qualifiedName === `${path.posix.basename(n.filePath, '.vue')}::${n.name}`; return n.kind === 'method' && n.filePath.endsWith('.vue') && n.qualifiedName === `${path.posix.basename(n.filePath, '.vue')}::${n.name}`;