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.
137 lines
5.6 KiB
TypeScript
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']);
|
|
});
|
|
});
|