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:
+30
-15
@@ -520,38 +520,49 @@ export function triggerInTree(root: SyntaxNode, source: string, line: number, co
|
||||
const col = column ?? firstNonBlankColumn(source, row);
|
||||
let node: SyntaxNode | null = innermostAt(root, row, col);
|
||||
let prev: SyntaxNode | null = null;
|
||||
// Whether the climb crossed an inline function: `onPress={() => go()}`
|
||||
// fires later, `behavior={isAndroid() ? 'a' : 'b'}` runs at render.
|
||||
let deferred = false;
|
||||
for (let up = 0; node && up < MAX_TRIGGER_CLIMB; up++, prev = node, node = node.parent) {
|
||||
const type = node.type;
|
||||
if (TRIGGER_BOUNDARIES.has(type)) return null;
|
||||
// A named handler is its own story: `const handleX = useCallback(() => …)`
|
||||
// binds a name, and whoever uses the name is the trigger of what is inside.
|
||||
if ((type === 'arrow_function' || type === 'function_expression') && node.parent) {
|
||||
if (type === 'arrow_function' || type === 'function_expression') {
|
||||
const p = node.parent;
|
||||
if (p.type === 'variable_declarator') return null;
|
||||
if (p.type === 'arguments' && p.parent) {
|
||||
const callee = calleeName(p.parent);
|
||||
if (p?.type === 'variable_declarator') return null;
|
||||
if (p?.type === 'arguments' && p.parent) {
|
||||
const callee = lastSegment(calleeText(p.parent));
|
||||
if (callee === 'useCallback' || callee === 'useMemo' || callee === 'useEffectEvent' || callee === 'useEvent') return null;
|
||||
}
|
||||
deferred = true;
|
||||
}
|
||||
if (type === 'jsx_attribute') {
|
||||
const name = node.namedChild(0);
|
||||
const propName = name ? name.text : 'prop';
|
||||
// An event prop, or any prop given a function: fired later. A value
|
||||
// computed in the attribute (`behavior={isAndroid() ? …}`) is not.
|
||||
if (!deferred && !/^on[A-Z]/.test(propName)) return null;
|
||||
const element = node.parent;
|
||||
const tag = element ? element.childForFieldName('name') : null;
|
||||
return { kind: 'prop', name: name ? name.text : 'prop', of: tag ? collapseText(tag.text) : null };
|
||||
return { kind: 'prop', name: propName, of: tag ? collapseText(tag.text) : null };
|
||||
}
|
||||
if (type === 'pair') {
|
||||
const key = node.childForFieldName('key');
|
||||
const keyText = key ? key.text.replace(/^['"`]|['"`]$/g, '') : '';
|
||||
if (/^on[A-Z]\w*$/.test(keyText)) {
|
||||
// `useFormik({ onSubmit: … })`: the object is an argument of a call.
|
||||
const object = node.parent;
|
||||
const args = object?.parent;
|
||||
const call = args?.type === 'arguments' ? args.parent : null;
|
||||
return { kind: 'option', name: keyText, of: call && CALL_TYPES.has(call.type) ? calleeName(call) : null };
|
||||
// `useFormik({ onSubmit: … })`, `Alert.alert(t, m, [{ onPress: … }])`:
|
||||
// the object — possibly inside an array — is an argument of a call.
|
||||
let holder: SyntaxNode | null = node.parent;
|
||||
for (let hop = 0; holder && hop < 4 && (holder.type === 'object' || holder.type === 'array' || holder.type === 'pair'); hop++) {
|
||||
holder = holder.parent;
|
||||
}
|
||||
const call = holder?.type === 'arguments' ? holder.parent : null;
|
||||
return { kind: 'option', name: keyText, of: call && CALL_TYPES.has(call.type) ? calleeText(call) : null };
|
||||
}
|
||||
}
|
||||
if (type === 'arguments' && node.parent && CALL_TYPES.has(node.parent.type) && prev !== null) {
|
||||
const callee = calleeName(node.parent);
|
||||
const callee = lastSegment(calleeText(node.parent));
|
||||
if (callee !== null && LATER_CALLEES.has(callee)) {
|
||||
const first = node.namedChild(0);
|
||||
const of = first && STRING_TYPES.has(first.type) ? cut(collapseText(first.text), MAX_ARG_TEXT) : null;
|
||||
@@ -562,11 +573,15 @@ export function triggerInTree(root: SyntaxNode, source: string, line: number, co
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The last segment of a call's callee: `nativeEmitter.addListener` → `addListener`. */
|
||||
function calleeName(call: SyntaxNode): string | null {
|
||||
/** A call's callee as written: `nativeEmitter.addListener`, `Alert.alert`, `useFormik`. */
|
||||
function calleeText(call: SyntaxNode): string | null {
|
||||
const callee = call.childForFieldName('function') ?? call.childForFieldName('constructor');
|
||||
if (!callee) return null;
|
||||
const text = collapseText(callee.text);
|
||||
return callee ? cut(collapseText(callee.text), 40) : null;
|
||||
}
|
||||
|
||||
/** The last segment of a callee: `nativeEmitter.addListener` → `addListener`. */
|
||||
function lastSegment(text: string | null): string | null {
|
||||
if (text === null) return null;
|
||||
const m = text.match(/([A-Za-z_$][\w$]*)\s*$/);
|
||||
return m ? m[1]! : text;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface WireStepSite {
|
||||
* summary of all of them.
|
||||
*/
|
||||
when: string;
|
||||
/** What fires THIS site, when it differs from the link's first. */
|
||||
trigger?: WireStepTrigger;
|
||||
}
|
||||
|
||||
/** What fires a step or a link: the event it is written under, and the function that writes it there. */
|
||||
@@ -385,9 +387,14 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
const viaKey = via.map((v) => v.id).join('>');
|
||||
const id = `${from.id} ${to.id} ${viaKey}`;
|
||||
const when = whens.filter((w, i) => w && whens.indexOf(w) === i).join(' && ');
|
||||
const stamped: WireStepSite = { ...site, when };
|
||||
const stamped: WireStepSite = { ...site, when, ...(trigger ? { trigger } : {}) };
|
||||
// A `contains` edge is how a nested handler is FOUND, not a place it is
|
||||
// called from: its row stays only while no call site has been seen.
|
||||
const structural = (s: WireStepSite) => s.text.startsWith('defines ');
|
||||
const existing = links.get(id);
|
||||
if (existing) {
|
||||
if (structural(stamped) && existing.sites.some((s) => !structural(s))) return;
|
||||
if (!structural(stamped) && existing.sites.every(structural)) existing.sites.length = 0;
|
||||
if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(stamped);
|
||||
if (!existing.trigger && trigger) existing.trigger = trigger;
|
||||
if (when !== existing.when) {
|
||||
|
||||
Reference in New Issue
Block a user