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:
Colby McHenry
2026-08-28 10:40:38 -05:00
parent e288d7645b
commit 5e06204deb
11 changed files with 245 additions and 38 deletions
+23 -3
View File
@@ -12,7 +12,7 @@
* are the links into and out of the selected step.
*/
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
import {
edgeLabel,
@@ -83,6 +83,21 @@ export function kindWord(kind: WireStep['kind']): string {
}
}
/**
* What fires something, in a few characters: `onPress · <Button>`,
* `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`.
*/
export function triggerWords(t: WireStepTrigger): string {
switch (t.kind) {
case 'prop':
return t.of ? `${t.name} · <${t.of}>` : t.name;
case 'option':
return t.of ? `${t.name} · ${t.of}(…)` : t.name;
default:
return t.of ? `${t.name}(${t.of})` : t.name;
}
}
/** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
export function stepLabel(step: WireStep): string {
switch (step.kind) {
@@ -105,7 +120,8 @@ export function stepSub(step: WireStep): string {
case 'screen':
return step.sub;
case 'trigger':
return `handler · ${file}`;
// The event before the file: `onPress · <Button> · index.tsx`.
return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
case 'bridge':
return `native · ${file}`;
case 'event':
@@ -177,12 +193,16 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
byKind: [{ kind: 'calls', count: group.length }],
topPairs: [],
});
// A link into a handler says the EVENT — `onPress · <Button>` — not the
// conditions; those are one hover away, and the event is what a reader
// asking "at what point does this run" came for.
const trigger = group.length === 1 && first.kind === 'handler' && first.trigger ? first.trigger : null;
edges.set(key, {
id: key,
from: first.from,
to: first.to,
links: group,
label: edgeLabel(group),
label: trigger ? triggerWords(trigger) : edgeLabel(group),
synthesized: group.every((l) => l.synthesized),
kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
});
+17
View File
@@ -710,6 +710,19 @@ export interface WireStepSite {
args?: string;
/** The conditions THIS site runs under (the whole chain's); '' when unconditional. */
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. */
export interface WireStepTrigger {
kind: 'prop' | 'option' | 'callback';
/** `onPress`, `onSubmit`, `useEffect`, `addListener`. */
name: string;
/** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
of: string | null;
/** The function the binding is written in. */
in: string;
}
export interface WireStep {
@@ -733,6 +746,8 @@ export interface WireStep {
event?: string;
/** Every event that lands on this step. */
events?: string[];
/** For a handler: what fires it. */
trigger?: WireStepTrigger;
screen?: { path: string; component: WireNodeRef | null };
/** The calls one function makes into one category, and the function. */
effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
@@ -752,6 +767,8 @@ export interface WireStepLink {
synthesized: boolean;
uncertain: boolean;
sites: WireStepSite[];
/** What fires the first site, when something binds it to an event. */
trigger?: WireStepTrigger;
}
export interface WireStepsPayload {
+7 -3
View File
@@ -428,7 +428,7 @@
>
<button class="peer mono" onclick={() => (selected = link.from)}>{sentence(link, 'from')}</button>
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
{#if link.via.length > 0}<div class="via">via {viaText(link)}</div>{/if}
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
<div class="scenario" class:many={sc.rows.length > 1}>
@@ -456,7 +456,7 @@
>
<button class="peer mono" onclick={() => (selected = link.to)}>{sentence(link, 'to')}</button>
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {viaText(link)}</div>{/if}
{#if link.via.length > 0}<div class="via">via {viaText(link)}</div>{/if}
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
<div class="scenario" class:many={sc.rows.length > 1}>
@@ -644,6 +644,8 @@
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
font-size: 12px;
pointer-events: none;
/* A long via chain or condition wraps inside the box. */
overflow-wrap: anywhere;
}
.tiprow {
display: flex;
@@ -736,8 +738,10 @@
.kw {
font-weight: 600;
}
/* The chain a transition travels through — the answer to "where on the screen": read, not dim. */
.via {
font: 400 11px var(--mono);
color: var(--ink-2);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.ways {
+40 -8
View File
@@ -38,6 +38,7 @@
stepNeighbourhood,
stepPairId,
stepViaText,
triggerWords,
type StepsModel,
} from '../lib/steps-model';
@@ -234,7 +235,7 @@
const box = stage.getBoundingClientRect();
hovered = {
edge,
x: Math.min(event.clientX - box.left + 14, box.width - 360),
x: Math.min(event.clientX - box.left + 14, box.width - 420),
y: event.clientY - box.top + 14,
};
}
@@ -262,7 +263,7 @@
}
hovered = {
edge,
x: Math.min(event.clientX - box.left + 14, box.width - 360),
x: Math.min(event.clientX - box.left + 14, box.width - 420),
y: event.clientY - box.top + 14,
};
}
@@ -399,7 +400,7 @@
</div>
<div class="lrow">
<span class="k-box mono">/path</span>
<span>A screen, or a handler — a function wired to a tap or a listener</span>
<span>A screen, or a handler — a function fired from a tap, an option, a listener; its line says the event</span>
</div>
<div class="lrow">
<span class="k-box k-cross mono">⇢ fn</span>
@@ -442,9 +443,10 @@
<div class="mono"><b>{nameOf(hoveredInfo.from)}</b>{nameOf(hoveredInfo.to)}</div>
{#each hoveredInfo.links.slice(0, 5) as link (link.id)}
<div class="tiprow">
{#if link.trigger}<span class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></span>{/if}
{#if link.via.length > 0}<span class="via">via {stepViaText(link)}</span>{/if}
{#if link.sites.length > 1}<span class="dim">{link.sites.length} ways</span>{/if}
<span class="when">{@render words(conditionTokens(link.when))}</span>
{#if link.via.length > 0}<span class="mono dim">via {stepViaText(link)}</span>{/if}
{#if link.label}<span class="dim">{link.label}</span>{/if}
{#if link.sites[0]}<span class="mono">{siteWords(link.sites[0])}</span>{/if}
</div>
@@ -462,6 +464,9 @@
<div>
<div class="mono big">{selectedInfo.label}</div>
<div class="sub dim">{kindWord(selectedInfo.step.kind)}{#if selectedInfo.step.anchor} · where the picture starts{/if}</div>
{#if selectedInfo.step.trigger}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(selectedInfo.step.trigger)} <span class="dim">in {selectedInfo.step.trigger.in}</span></div>
{/if}
{#if selectedInfo.step.screen?.component}
<a class="sub" href={symbolHref(selectedInfo.step.screen.component.id)}>
<KindGlyph kind={selectedInfo.step.screen.component.kind} />
@@ -533,13 +538,17 @@
onfocusout={() => onRowHover(null)}
>
<button class="peer mono" onclick={() => (selected = link.from)}>{nameOf(link.from)}</button>
{#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
{#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
{#if link.label}<div class="via dim">{link.label}</div>{/if}
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
{@const href = siteHref(link, row.site, fallback)}
<div class="scenario" class:many={sc.rows.length > 1}>
{#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
{/if}
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
@@ -571,13 +580,17 @@
onfocusout={() => onRowHover(null)}
>
<button class="peer mono" onclick={() => (selected = link.to)}>{nameOf(link.to)}</button>
{#if link.trigger}<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(link.trigger)} <span class="dim">in {link.trigger.in}</span></div>{/if}
{#if link.via.length > 0}<div class="via">via {stepViaText(link)}</div>{/if}
{#if sc.common.length > 0}<div class="when">{@render words(commonTokens(sc.common))}</div>{/if}
{#if link.via.length > 0}<div class="via dim">via {stepViaText(link)}</div>{/if}
{#if link.label}<div class="via dim">{link.label}</div>{/if}
{#if sc.rows.length > 1}<div class="ways dim">{sc.rows.length} ways</div>{/if}
{#each sc.rows as row (row.site.file + row.site.line)}
{@const href = siteHref(link, row.site, fallback)}
<div class="scenario" class:many={sc.rows.length > 1}>
{#if row.site.trigger && triggerWords(row.site.trigger) !== (link.trigger ? triggerWords(link.trigger) : '')}
<div class="fires"><b class="kw">FIRES FROM</b> {triggerWords(row.site.trigger)}</div>
{/if}
{#if sc.rows.length > 1}<div class="when">{@render words(restTokens(row.rest, sc.common.length > 0))}</div>{/if}
{#if href}
<a class="site" {href}>{siteWords(row.site)} <span class="dim">· {basename(row.site.file)}:{row.site.line}</span></a>
@@ -809,13 +822,21 @@
.tip {
position: absolute;
z-index: 5;
width: 340px;
width: 400px;
padding: 8px 10px;
border: 1px solid var(--ink);
background: var(--paper);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
font-size: 12px;
pointer-events: none;
/* A call with its arguments is one long token: it wraps inside the box. */
overflow-wrap: anywhere;
}
.tip .mono,
.tip .when,
.tip .via,
.tip .fires {
overflow-wrap: anywhere;
}
.tiprow {
display: flex;
@@ -930,8 +951,19 @@
.kw {
font-weight: 600;
}
/* The chain a hop travels through — the answer to "where on the screen": read, not dim. */
.via {
font: 400 11px var(--mono);
color: var(--ink-2);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.via.dim {
color: var(--ink-3);
font-size: 11px;
}
.fires {
color: var(--ink);
font: 400 11.5px var(--mono);
margin-top: 2px;
}
.ways {