Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- In JavaScript and TypeScript, a call on a variable declared outside the calling function with a package's type, like react-dom's `root.render(<App />)` on a module-level `let root: Root | null`, no longer links to a project method of the same name, such as an error boundary's `render()`. (#2589)
- 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 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)
Expand Down
245 changes: 245 additions & 0 deletions __tests__/ts-outer-declared-receiver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/**
* A JS/TS receiver declared with a package's type in a function around the
* calling one, or at the module's top level, calls none of the project's
* methods (#2589).
*
* react-dom's `root.render(<X />)` went to the project's one class `render()`
* (an error boundary) when `root` was a module's `let root: Root | null`, or
* a `let popupRoot: Root | null` of an enclosing function a nested one
* renders through: the receiver's type was read only in the calling function.
* A receiver typed there already got no edge.
*
* Anything nearer that binds the name — a local, a parameter, a destructured
* name, a later declarator — decides instead, and a binding inside another
* function is not in scope. A project type declared this way is not used to
* resolve a call: those are guessed as before.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});

const FILES: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0', typedi: '^0.10.0' } }),
'src/Boundary.tsx': `import { Component } from "react";
export class Boundary extends Component<{ children?: React.ReactNode }> {
render() { return this.props.children; }
}
export function makeBoundary() { return new Boundary(); }
`,
'src/popup.tsx': `import { createRoot, type Root } from "react-dom/client";
import { useState } from "react";
import { Container } from "typedi";
import { Boundary, makeBoundary } from "./Boundary";

let root: Root | null = null;
export function showPopup(el: HTMLElement) {
if (!root) root = createRoot(el);
root.render(<div />);
}

let modRoot: Root;
export function a1(el: HTMLElement) { modRoot = createRoot(el); modRoot.render(<div />); }

export function outer(el: HTMLElement) {
let popupRoot: Root | null = null;
function renderPopup() { popupRoot!.render(<div />); }
popupRoot = createRoot(el);
return renderPopup;
}

export function typedHere(el: HTMLElement) {
const t: Root = createRoot(el);
t.render(<div />);
}

let own: Boundary;
export function ownTyped() { own.render(); }

export function ownNew() { const b = new Boundary(); b.render(); }

export function ownCall() { const m = makeBoundary(); m.render(); }

export function shadowed(root: any) { root.render(); }

export function arrowParam(items: Boundary[]) { items.forEach((root) => root.render()); }

export function destructured() { const [root] = useState(new Boundary()); root.render(); }

export function caught() { try { makeBoundary(); } catch (root: any) { root.render(); } }

export function injected() { const root = Container.get(Boundary); root.render(); }

let loose: any;
export function untypedOuter() { loose = new Boundary(); loose.render(); }

export function sibling() { let other: Root | null = null; return other; }
export function afterSibling() { const b = makeBoundary(); other.render(); }

let wrapped: InstanceType<typeof Boundary>;
export function viaWrapper() { wrapped.render(); }

// let commented: Root;
let commented = makeBoundary();
export function afterComment() { commented.render(); }

let quoted = makeBoundary();
const snippet = \`
let quoted: Root;
\`;
export function afterString() { quoted.render(); return snippet; }

let blockRoot: Root | null = null;
export function inBlock(el: HTMLElement) {
if (!blockRoot) {
blockRoot = createRoot(el);
}
blockRoot.render(<div />);
}
`,
'src/MapView.ts': `export default class MapView { draw() { return 1; } }
`,
'src/map.ts': `import Map from "./MapView";
let map: Map;
export function viaMap() { map.draw(); }
`,
};

/** The module's `ob` is a react-dom `Root`; the project's one `render()` is `Other`'s. */
const SHADOW: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { 'react-dom': '^19.0.0' } }),
'src/Other.tsx': `export class Other { render() { return 2; } }
export function makeOther() { return new Other(); }
`,
'src/shadow.tsx': `import type { Root } from "react-dom/client";
import { Other, makeOther } from "./Other";

let ob: Root | null = null;
export function outside() { ob!.render(); }
export function fnExpr(items: Other[]) { items.forEach(function (ob) { ob.render(); }); }
export function laterDeclarator() { let n = 0, ob = makeOther(); ob.render(); }
export function multiLine() {
const {
ob,
} = { ob: makeOther() };
ob.render();
}
export function declaratorBelow() {
const a = 1,
ob = makeOther();
ob.render();
}
export function destructDefault(o: any) { const { ob = makeOther() } = o; ob.render(); }
export function multiDefault(o: any) {
const {
ob = makeOther(),
} = o;
ob.render();
}
export function commentedDeclarator() {
let n = 0, // counter
ob = makeOther();
ob.render();
}
export const returnsArrow = () => { return ob => ob.render(); };
`,
};

/** The project's one `render()` is a namesake of the package type a receiver is declared with. */
const NAMESAKE: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { 'react-dom': '^19.0.0' } }),
'src/Root.ts': `export class Root { render() { return 3; } }
`,
'src/use.ts': `import * as RD from "react-dom/client";
let qualified: RD.Root | null = null;
export function viaNamespace() { qualified!.render(); }
`,
};

/** A project file declares the package, adding to its `Root`. */
const AUGMENTED: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { 'react-dom': '^19.0.0' } }),
'src/aug.d.ts': `declare module "react-dom/client" {
interface Root { extra(): void }
}
`,
'src/use.ts': `import type { Root } from "react-dom/client";
let augmented: Root;
export function viaAugmentation() { augmented.extra(); }
`,
};

async function indexed(files: Record<string, string>): Promise<CodeGraph> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-outer-receiver-'));
roots.push(dir);
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
}
return CodeGraph.init(dir, { index: true });
}

/** The names of the functions with a `calls` edge to the method `qualifiedName`. */
function callersOf(cg: CodeGraph, file: string, qualifiedName: string): Set<string | undefined> {
const method = cg.getNodesInFile(file).find((n) => n.kind === 'method' && n.qualifiedName === qualifiedName)!;
expect(method).toBeDefined();
return new Set(cg.getIncomingEdgesTo([method.id], ['calls']).map((e) => cg.getNode(e.source)?.name));
}

describe('JS/TS: a receiver typed in a scope around the call', () => {
it('has that type; a nearer binding, or one in another function, does not', async () => {
const cg = await indexed(FILES);
try {
const callers = callersOf(cg, 'src/Boundary.tsx', 'Boundary::render');
for (const outside of ['showPopup', 'a1', 'renderPopup', 'typedHere', 'inBlock']) {
expect(callers.has(outside), outside).toBe(false);
}
for (const project of ['ownTyped', 'ownNew', 'ownCall', 'shadowed', 'arrowParam', 'destructured', 'caught', 'injected', 'untypedOuter', 'afterSibling', 'viaWrapper', 'afterComment', 'afterString']) {
expect(callers.has(project), project).toBe(true);
}
// A project class imported under a built-in's name is the project's.
expect(callersOf(cg, 'src/MapView.ts', 'MapView::draw').has('viaMap')).toBe(true);
} finally {
cg.close();
}
});

it('gives way to a nearer binding of the name, which still reaches the project', async () => {
const cg = await indexed(SHADOW);
try {
const callers = callersOf(cg, 'src/Other.tsx', 'Other::render');
expect(callers.has('outside'), 'outside').toBe(false);
for (const nearer of ['fnExpr', 'laterDeclarator', 'multiLine', 'declaratorBelow', 'destructDefault', 'multiDefault', 'commentedDeclarator', 'returnsArrow']) {
expect(callers.has(nearer), nearer).toBe(true);
}
} finally {
cg.close();
}
});

it("leaves a package type the project declares to the project's declaration", async () => {
const cg = await indexed(AUGMENTED);
try {
const extra = cg.getNodesInFile('src/aug.d.ts').find((n) => n.name === 'extra')!;
expect(extra).toBeDefined();
expect(cg.getIncomingEdgesTo([extra.id], ['calls']).map((e) => cg.getNode(e.source)?.name)).toContain('viaAugmentation');
} finally {
cg.close();
}
});

it("keeps a package namespace's type, not the project's namesake", async () => {
const cg = await indexed(NAMESAKE);
try {
expect(callersOf(cg, 'src/Root.ts', 'Root::render').has('viaNamespace')).toBe(false);
} finally {
cg.close();
}
});
});
Loading