fix(extraction): index const-bound functions inside a body as symbols (#1669) (#1774)

`const handleClear = () => {…}` inside a component — every React handler
that skips useCallback — was never a symbol: the body walker only named
nested function declarations and hook-bound arrows, so the handler was
absent from callers/impact ("Symbol not found", indistinguishable from
"no callers") and its calls attributed to the component. Bind the arrow
or function expression to its declarator the way module scope already
does, in both the wasm walker and the kernel.

A navigation such a handler makes is now the handler's own edge and a hop
in the Screens `via` chain — the shape a useCallback handler already has —
so the react-router and expo-router expectations follow that convention.

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 10:07:46 -05:00
committed by GitHub
co-authored by danusha2345 Colby McHenry
parent 85550eb2ce
commit 8733c2880f
7 changed files with 173 additions and 7 deletions
+2 -1
View File
@@ -560,7 +560,8 @@ describe('expo-router: end-to-end', () => {
const detail = screens.screens.find((s) => s.path === '/object-detail')!;
const tap = screens.links.find((l) => l.from === home.id && l.to === detail.id)!;
expect(tap).toBeDefined();
expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'openObjectDetail']);
// `handlePress` is a symbol of its own (#1669), so the tap passes through it.
expect(tap.via.map((v) => v.name)).toEqual(['ItemCard', 'handlePress', 'openObjectDetail']);
expect(tap.when).toBe('props.collected');
expect(tap.sites[0]!.href).toBe('/object-detail?detectionItem=${…}');
// Navigation nothing on a screen reaches is an origin, not dropped: the
@@ -212,3 +212,13 @@ import('./dynamic-module');
new NS.Widget(makeArg());
new Map<string, number>();
super_weird?.();
// --- const-bound functions inside a body (#1669) -----------------------------
export function NestedHandlers({ items, onPick }: { items: string[]; onPick: (a: unknown, b: unknown) => void }) {
const handleClear = () => { onPick(null, null); };
const describe = function (item: string) { return formatLabel(item); };
let later = (x: string) => parseLabel(x);
const count = items.length;
const [a, b] = [() => 1, () => 2];
return items.map((i) => <button onClick={handleClear} onDoubleClick={() => describe(i)}>{later(i)}{count}{a()}{b()}</button>);
}
@@ -0,0 +1,76 @@
/**
* A function bound by a `const` inside another function is a symbol (#1669).
*
* `const handleClear = () => {…}` inside a component is how every React
* handler that skips `useCallback` is written. At module scope the same
* declaration already names a function; inside a body it was skipped, so the
* handler was absent from callers / impact — "Symbol not found", which reads
* exactly like "no callers" — and its calls attributed to the component.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const refsFrom = (result: ReturnType<typeof extractFromSource>, id: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
describe('declarator-bound functions inside a body', () => {
it('extracts const arrows and function expressions as functions of the enclosing one', () => {
const code = `
import { formatLabel, parseLabel } from './labels'
export default function Widget({ items, onPick }) {
const handleClear = () => {
onPick(null, null)
}
const describe = function (item) {
return formatLabel(item)
}
let later = (x) => parseLabel(x)
const count = items.length
const [a, b] = [() => 1, () => 2]
return items.map((i) => <button onClick={handleClear} onDoubleClick={() => describe(i)}>{later(i)}</button>)
}
`;
const result = extractFromSource('src/widget.jsx', code);
const fns = result.nodes.filter((n) => n.kind === 'function');
const names = fns.map((n) => n.name);
expect(names).toEqual(expect.arrayContaining(['Widget', 'handleClear', 'describe', 'later']));
// A value, a destructuring and an inline arrow stay out.
expect(names).not.toContain('count');
expect(names).not.toContain('a');
expect(names.filter((n) => n === '<anonymous>')).toEqual([]);
const widget = fns.find((n) => n.name === 'Widget')!;
const handleClear = fns.find((n) => n.name === 'handleClear')!;
const describeFn = fns.find((n) => n.name === 'describe')!;
expect(handleClear.qualifiedName).toBe('Widget::handleClear');
expect(handleClear.startLine).toBe(4);
expect(describeFn.startLine).toBe(7);
// The handler's calls are its own; the component keeps what it does itself.
expect(refsFrom(result, handleClear.id)).toContain('onPick');
expect(refsFrom(result, widget.id)).not.toContain('onPick');
expect(refsFrom(result, describeFn.id)).toContain('formatLabel');
expect(refsFrom(result, widget.id)).toContain('handleClear');
// Containment: the component contains its handlers.
const contains = result.edges.filter((e) => e.kind === 'contains' && e.source === widget.id).map((e) => e.target);
expect(contains).toContain(handleClear.id);
expect(contains).toContain(describeFn.id);
});
it('does not apply outside the JS family', () => {
const code = `
def outer():
inner = lambda x: x + 1
return inner(1)
`;
const result = extractFromSource('src/mod.py', code);
expect(result.nodes.filter((n) => n.kind === 'function').map((n) => n.name)).toEqual(['outer']);
});
});
+22 -6
View File
@@ -229,6 +229,14 @@ describe('react-router: a routed app end to end', () => {
if (!n) throw new Error(`no symbol ${name}`);
return n;
};
// A handler written as `const submitHandler = () => {…}` inside a screen is a
// symbol of its own (#1669), so a navigation it makes is ITS edge — the same
// shape a `useCallback` handler has — and the screen reaches it by calling it.
const symIn = (name: string, file: string): Node => {
const n = cg.getNodesByName(name).find((n) => n.kind !== 'route' && n.kind !== 'file' && n.kind !== 'import' && n.filePath.endsWith(file));
if (!n) throw new Error(`no symbol ${name} in ${file}`);
return n;
};
const navs = (from: Node) => cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'navigates');
const hrefs = (from: Node) =>
navs(from)
@@ -250,17 +258,24 @@ describe('react-router: a routed app end to end', () => {
it('the payment screen pushes to both pages it leads to — the bounce out and the one on submit', () => {
const payment = sym('PaymentScreen');
expect(hrefs(payment)).toEqual(['/placeorder', '/shipping']);
const byHref = new Map(navs(payment).map((e) => [(e.metadata as Record<string, unknown>).href, e]));
const submit = symIn('submitHandler', 'PaymentScreen.js');
// The bounce-out is the component's own; the push on submit belongs to its handler.
expect(hrefs(payment)).toEqual(['/shipping']);
expect(hrefs(submit)).toEqual(['/placeorder']);
// `onSubmit={submitHandler}` is the screen's reference to it; the Screens
// walk below rides that hop.
expect(cg.getOutgoingEdges(payment.id).some((e) => e.target === submit.id && e.kind === 'references')).toBe(true);
const byHref = new Map([...navs(payment), ...navs(submit)].map((e) => [(e.metadata as Record<string, unknown>).href, e]));
expect(byHref.get('/shipping')!.target).toBe(route('/shipping').id);
expect(byHref.get('/placeorder')!.target).toBe(route('/placeorder').id);
expect(byHref.get('/placeorder')!.metadata).toMatchObject({ navMethod: 'push' });
});
it('history.replace navigates, and v6s navigate() with a template hole reaches the :id route', () => {
expect(navs(sym('ShippingScreen'))[0]!.target).toBe(route('/payment').id);
expect(navs(sym('ShippingScreen'))[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
const product = navs(sym('ProductScreen'));
const shippingSubmit = symIn('submitHandler', 'ShippingScreen.js');
expect(navs(shippingSubmit)[0]!.target).toBe(route('/payment').id);
expect(navs(shippingSubmit)[0]!.metadata).toMatchObject({ href: '/payment', navMethod: 'replace' });
const product = navs(sym('addToCart'));
expect(product).toHaveLength(1);
expect(product[0]!.target).toBe(route('/cart/:id?').id);
expect(product[0]!.metadata).toMatchObject({ href: '/cart/${…}', navMethod: 'navigate' });
@@ -288,7 +303,8 @@ describe('react-router: a routed app end to end', () => {
const link = screens.links.find((l) => l.from === at('/payment').id && l.to === at('/placeorder').id)!;
expect(link).toBeDefined();
expect(link.sites[0]).toMatchObject({ href: '/placeorder', method: 'push' });
expect(link.via).toEqual([]);
// The submit handler is the hop between the screen and the push.
expect(link.via.map((v) => v.name)).toEqual(['submitHandler']);
expect(screens.links.find((l) => l.from === at('/shipping').id && l.to === at('/payment').id)).toBeDefined();
expect(screens.links.find((l) => l.from === at('/product/:id').id && l.to === at('/cart/:id?').id)).toBeDefined();
});