Files
codegraph/__tests__/react-hook-handlers.test.ts
T
Colby McHenry 873f133c96 feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
2026-08-28 09:46:50 -05:00

137 lines
5.6 KiB
TypeScript

/**
* React handler hooks name the function they wrap.
*
* `const handleSubmit = useCallback(() => {…}, [])` is how nearly every
* handler in a React / React Native component is written, and the arrow is
* anonymous only syntactically — the declarator is the name every
* `onPress={handleSubmit}` and `addListener('x', handleSubmit)` uses. Without
* a node the handler's calls attribute to the component and the trigger of a
* flow (the tap, the native event) has nothing to resolve to.
*/
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('useCallback handlers', () => {
it('extracts the wrapped arrow as a function named by the declarator, inside the component', () => {
const code = `
import { useCallback, useMemo, useEffect } from 'react'
import { finalize, upload, log } from './api'
export default function ReviewScreen() {
const handleApprove = useCallback(() => {
finalize()
}, [])
const handleZip = useCallback(async (data: { uri: string }) => {
await upload(data.uri)
}, [])
const total = useMemo(() => 1 + 1, [])
useEffect(() => {
log('mounted')
}, [])
return <Button onPress={handleApprove} />
}
`;
const result = extractFromSource('src/app/review.tsx', code);
const fns = result.nodes.filter((n) => n.kind === 'function');
const names = fns.map((n) => n.name);
expect(names).toEqual(expect.arrayContaining(['ReviewScreen', 'handleApprove', 'handleZip']));
// A memo is a value and an effect is anonymous: neither becomes a function.
expect(names).not.toContain('total');
expect(names.filter((n) => n === '<anonymous>')).toEqual([]);
const screen = fns.find((n) => n.name === 'ReviewScreen')!;
const handleZip = fns.find((n) => n.name === 'handleZip')!;
expect(handleZip.qualifiedName).toBe('ReviewScreen::handleZip');
expect(handleZip.startLine).toBe(8);
// The handler's calls are its own; the component keeps only what it does itself.
expect(refsFrom(result, handleZip.id)).toContain('upload');
expect(refsFrom(result, screen.id)).not.toContain('upload');
expect(refsFrom(result, screen.id)).toContain('log');
// Containment: the component contains its handlers.
expect(
result.edges.some((e) => e.kind === 'contains' && e.source === screen.id && e.target === handleZip.id)
).toBe(true);
// `onPress={handleApprove}` is a function-as-value site: the tap's handler
// is referenced from the component, which is how a Steps picture knows
// the handler is a trigger.
const handleApprove = fns.find((n) => n.name === 'handleApprove')!;
expect(
result.unresolvedReferences.some(
(r) => r.fromNodeId === screen.id && r.referenceKind === 'function_ref' && r.referenceName === 'handleApprove'
)
).toBe(true);
expect(handleApprove.startLine).toBe(5);
});
it('accepts React.useCallback, function expressions, and useEffectEvent', () => {
const code = `
import React from 'react'
export function Screen() {
const onOpen = React.useCallback(function () { open() }, [])
const onLog = useEffectEvent((url: string) => { track(url) })
return null
}
`;
const result = extractFromSource('src/screen.tsx', code);
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(names).toEqual(expect.arrayContaining(['Screen', 'onOpen', 'onLog']));
});
it('leaves a hook whose first argument is not the bound function alone', () => {
const code = `
export function Screen() {
const value = useState(() => compute())
const cb = useCallback(existingHandler, [])
const [x] = useReducer((s) => s, 0)
return null
}
`;
const result = extractFromSource('src/screen.tsx', code);
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(names).toEqual(['Screen']);
});
it('a handler a hook returns in an object is a function-as-value of the hook', () => {
const code = `
import { useCallback } from 'react'
export function useReviewHandlers() {
const handleApprove = useCallback(() => { finalize() }, [])
const handleRetake = useCallback(() => { retake() }, [])
const count = 1
return { handleApprove, handleRetake, count, extra: helper }
}
function helper() {}
`;
const result = extractFromSource('src/hooks.ts', code);
const hook = result.nodes.find((n) => n.name === 'useReviewHandlers')!;
const fnRefs = result.unresolvedReferences
.filter((r) => r.fromNodeId === hook.id && r.referenceKind === 'function_ref')
.map((r) => r.referenceName)
.sort();
// `count` is a value, not a function defined here: gated out.
expect(fnRefs).toEqual(['handleApprove', 'handleRetake', 'helper']);
});
it('does nothing outside the JS family', () => {
const code = `
func screen() {
let handle = useCallback({ () in finalize() }, [])
}
`;
const result = extractFromSource('Screen.swift', code);
const names = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
expect(names).toEqual(['screen']);
});
});