/**
* react-dom's `root.render()` 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): Promise {
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();\n}\n',
'src/popup2.tsx':
'import { createRoot } from "react-dom/client";\nexport function mount(el: HTMLElement) {\n const r = createRoot(el);\n r.render();\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();\n}\n' +
'export function mount4(root: Root) {\n root.render();\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(); }\n\n' +
'export function outer(el: HTMLElement) {\n let popupRoot: Root | null = null;\n function renderPopup() { popupRoot!.render(); }\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();\n}\n' +
'const shared = ReactDOM.hydrateRoot(el, );\nexport function rerender() {\n shared.render();\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();\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(); }\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);
});