feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
- Introduces trigger metadata for steps and edges to capture what fires a site (JS prop, on* option, or callback) to improve cross-boundary flow analysis. - Extends parsing/analysis to detect triggers in JSX attributes, on* bindings, and late-bound callbacks; adds utilities (calleeText, lastSegment) to extract trigger sources. - Ships new trigger structures (WireStepTrigger, trigger on WireStepSite/WireStep) and propagates trigger through built steps; updates step labeling to reflect trigger information. - Adds triggerWords helper and uses it to render human-readable trigger descriptions in Steps UI, including edge labels and per-site visuals. - Updates UI (ScreensView, StepsView) to display FIRES FROM information, with styling tweaks to highlight triggers and related elements; enhances tooltips and inline text wrapping for readability. - Extends tests to cover trigger detection and rendering across various binding patterns (prop, option, callback) and inline RN listeners. - Updates design/docs and changelog to reflect Expo Router integration, per-site trigger metadata, and the new Steps surface.
This commit is contained in:
@@ -4,7 +4,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { initGrammars } from '../src/extraction/grammars';
|
||||
import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards';
|
||||
import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards, triggerInSource } from '../src/graph/branch-guards';
|
||||
import { buildNode } from '../src/ui-server/api/node';
|
||||
import { buildFlow } from '../src/ui-server/api/flow';
|
||||
|
||||
@@ -307,3 +307,81 @@ class CaptureEvents {
|
||||
expect(await argsAt(src, 'DispatchQueue.main.async', 'swift')).toBe('{ … }');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Triggers — what fires a site
|
||||
// =============================================================================
|
||||
|
||||
async function triggerAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') {
|
||||
const line = lineOf(src, needle);
|
||||
const column = src.split('\n')[line - 1]!.indexOf(needle);
|
||||
return triggerInSource(src, language, line, column);
|
||||
}
|
||||
|
||||
describe('triggers', () => {
|
||||
const login = `
|
||||
function LoginButton({ values }) {
|
||||
const formik = useFormik({
|
||||
initialValues: values,
|
||||
onSubmit: (v) => {
|
||||
handleLogin(v.email, v.password)
|
||||
},
|
||||
})
|
||||
useEffect(() => {
|
||||
warmUp()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const sub = nativeEmitter.addListener('onZipComplete', (data) => { finish(data) })
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
const handleRemove = useCallback(() => {
|
||||
removeCredential(values.email)
|
||||
}, [values])
|
||||
fetchThing().then(() => done())
|
||||
return (
|
||||
<View>
|
||||
<Button onPress={formik.submitForm} />
|
||||
<TouchableOpacity onPress={() => handleSelectAccount(account)} />
|
||||
<Pressable onPress={handleRemove} />
|
||||
<Row.Item onLongPress={() => { if (ok) confirm() }} />
|
||||
<KeyboardAvoidingView behavior={isAndroid() ? 'height' : 'padding'} />
|
||||
<FlatList renderItem={({ item }) => renderRow(item)} keyExtractor={keyOf} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
function warn() {
|
||||
Alert.alert('Remove?', 'Sure?', [{ text: 'OK', onPress: () => removeAll() }], { cancelable: true })
|
||||
}
|
||||
`;
|
||||
|
||||
it('a call under a JSX prop: the prop and the element', async () => {
|
||||
expect(await triggerAt(login, 'handleSelectAccount(')).toEqual({ kind: 'prop', name: 'onPress', of: 'TouchableOpacity' });
|
||||
expect(await triggerAt(login, 'confirm()')).toEqual({ kind: 'prop', name: 'onLongPress', of: 'Row.Item' });
|
||||
// A handler passed as a value: the site IS the attribute.
|
||||
expect(await triggerAt(login, 'handleRemove} />')).toEqual({ kind: 'prop', name: 'onPress', of: 'Pressable' });
|
||||
// A function under any prop fires later; a value computed in a prop runs at render.
|
||||
expect(await triggerAt(login, 'renderRow(item)')).toEqual({ kind: 'prop', name: 'renderItem', of: 'FlatList' });
|
||||
expect(await triggerAt(login, 'isAndroid()')).toBeNull();
|
||||
expect(await triggerAt(login, 'keyOf}')).toBeNull();
|
||||
});
|
||||
|
||||
it('a call under an on* option: the key and the call it configures', async () => {
|
||||
expect(await triggerAt(login, 'handleLogin(')).toEqual({ kind: 'option', name: 'onSubmit', of: 'useFormik' });
|
||||
// The option's object inside an array argument: still the call it configures.
|
||||
expect(await triggerAt(login, 'removeAll()')).toEqual({ kind: 'option', name: 'onPress', of: 'Alert.alert' });
|
||||
});
|
||||
|
||||
it('a call inside a runs-later callback: the callee and its first literal', async () => {
|
||||
expect(await triggerAt(login, 'warmUp()')).toEqual({ kind: 'callback', name: 'useEffect', of: null });
|
||||
expect(await triggerAt(login, 'finish(data)')).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'" });
|
||||
expect(await triggerAt(login, 'done()')).toEqual({ kind: 'callback', name: 'then', of: null });
|
||||
});
|
||||
|
||||
it('a named handler is its own story: nothing fires the call inside it, from here', async () => {
|
||||
expect(await triggerAt(login, 'removeCredential(')).toBeNull();
|
||||
// A plain call in a component body is fired by nothing in particular.
|
||||
expect(await triggerAt(login, 'fetchThing()')).toBeNull();
|
||||
expect(await triggerAt(login, 'handleLogin(', 'swift')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,10 @@ beforeAll(async () => {
|
||||
" const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
|
||||
' return () => sub.remove()\n' +
|
||||
' }, [handleZipComplete])\n' +
|
||||
' const form = useForm({ onSubmit: () => handleSubmit() })\n' +
|
||||
' function handleSubmit() {\n' +
|
||||
' captureView.finalizeCaptureSession()\n' +
|
||||
' }\n' +
|
||||
' return <Button onPress={handleApprove} />\n' +
|
||||
'}\n'
|
||||
);
|
||||
@@ -91,7 +95,7 @@ beforeAll(async () => {
|
||||
' const handleOpen = useCallback(() => {\n' +
|
||||
' captureView.finalizeCaptureSession()\n' +
|
||||
' }, [])\n' +
|
||||
' return <Button onPress={handleOpen} />\n' +
|
||||
' return <Button onPress={() => handleOpen()} />\n' +
|
||||
'}\n' +
|
||||
'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
|
||||
'export default function CapturePage() {\n' +
|
||||
@@ -194,7 +198,18 @@ describe('buildSteps', () => {
|
||||
const link = (from: string, to: string) =>
|
||||
payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
|
||||
const req = link('handleZipComplete', 'client.post +1');
|
||||
expect(link('/capture/review', 'handleApprove')?.kind).toBe('handler');
|
||||
const tap = link('/capture/review', 'handleApprove');
|
||||
expect(tap?.kind).toBe('handler');
|
||||
// What fires it — read at the site: the JSX prop and its element, and the
|
||||
// function that writes the binding.
|
||||
expect(tap?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
|
||||
expect(byLabel.get('handleApprove')?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
|
||||
// A function called from under an `on*` option is a handler too — the
|
||||
// Formik shape — and the option names what fires it.
|
||||
expect(kinds['handleSubmit']).toBe('trigger');
|
||||
expect(link('/capture/review', 'handleSubmit')?.trigger).toEqual({ kind: 'option', name: 'onSubmit', of: 'useForm', in: 'ReviewScreen' });
|
||||
// The listener registration is a callback binding on the handler link.
|
||||
expect(link('/capture/review', 'handleZipComplete')?.trigger).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'ReviewScreen' });
|
||||
expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
|
||||
const evt = link('finalizeCaptureSession', 'handleZipComplete');
|
||||
expect(evt?.kind).toBe('event');
|
||||
@@ -245,11 +260,13 @@ describe('buildSteps', () => {
|
||||
const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
|
||||
const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
|
||||
// The wrapper and the component are render hops, folded into the link;
|
||||
// the handler is the first box, the native call the next.
|
||||
// the handler — called from an inline arrow under `onPress` — is the
|
||||
// first box, the native call the next.
|
||||
expect(kinds['handleOpen']).toBe('trigger');
|
||||
expect(kinds['finalizeCaptureSession']).toBe('bridge');
|
||||
const toHandler = payload.links.find((l) => l.to === payload.steps.find((s) => s.label === 'handleOpen')!.id)!;
|
||||
expect(toHandler.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
|
||||
expect(toHandler.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'CaptureComponent' });
|
||||
expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* rule, and the panel's two lists.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildStepsModel, kindWord, stepLabel, stepNeighbourhood, stepSub, stepViaText } from '../ui/src/lib/steps-model';
|
||||
import { buildStepsModel, kindWord, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
|
||||
import { placeLabels } from '../ui/src/lib/screens-model';
|
||||
import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire';
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('steps model', () => {
|
||||
const store = step('setZipUri', 'store', 4, { node: ref('setZipUri', 'src/storage/capture.storage.ts') });
|
||||
const home = step('/', 'screen', 4, { screen: { path: '/', component: null } });
|
||||
const links = [
|
||||
link(screen, handler, { kind: 'handler' }),
|
||||
link(screen, handler, { kind: 'handler', trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } }),
|
||||
link(handler, bridge, { kind: 'bridge', when: '!busy' }),
|
||||
link(bridge, event, { kind: 'event', synthesized: true, via: [ref('emitZipComplete', 'ios/CaptureEvents.swift', 'swift')], when: 'result', label: 'via rn-event-channel · event onZipComplete' }),
|
||||
link(event, effect, { kind: 'effect', via: [ref('uploadARCapture')] }),
|
||||
@@ -69,6 +69,9 @@ describe('steps model', () => {
|
||||
it('one edge per pair, labelled with the innermost condition or a count', () => {
|
||||
const edges = [...model.edges.values()];
|
||||
expect(edges).toHaveLength(6);
|
||||
// A link into a handler says the event, not the conditions.
|
||||
const toHandler = edges.find((e) => e.to === handler.id)!;
|
||||
expect(toHandler.label).toBe('onPress · <Button>');
|
||||
const toBridge = edges.find((e) => e.to === bridge.id)!;
|
||||
expect(toBridge.label).toBe('NOT busy');
|
||||
expect(toBridge.kind).toBe('bridge');
|
||||
@@ -95,6 +98,10 @@ describe('steps model', () => {
|
||||
expect(stepSub(store)).toBe('store · capture.storage.ts');
|
||||
expect(stepSub(effect)).toBe('network · uploadARCapture');
|
||||
expect(kindWord('effect')).toBe('outside the index');
|
||||
expect(triggerWords({ kind: 'option', name: 'onSubmit', of: 'useFormik', in: 'LoginButton' })).toBe('onSubmit · useFormik(…)');
|
||||
expect(triggerWords({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'X' })).toBe("addListener('onZipComplete')");
|
||||
expect(triggerWords({ kind: 'callback', name: 'useEffect', of: null, in: 'X' })).toBe('useEffect');
|
||||
expect(stepSub({ ...handler, trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } })).toBe('onPress · <Button> · a.tsx');
|
||||
expect(stepViaText(links[2]!)).toBe('emitZipComplete');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user