feat(steps): draw all arms of conditional navigations as separate edges

Adds multi-arm navigation support: when a destination is produced by a conditional, every arm is now drawn as its own edge. Introduces helpers (hrefArms, destinationsForHref) and updates framework resolvers and edge creation to emit multiple navigates edges (via alsoTargets) instead of a single one. Also introduces per-app rooted route tables to avoid cross-app crossings, and updates various resolvers (React Router, TanStack Router, Vue Router, SvelteKit, Vue, and SvelteKit’s linker) and the UI to reflect multiple possible destinations. Tests and docs updated to reflect the new behavior, ensuring the Screens tab shows all possible navigation paths from conditional destinations. This makes navigation visualization more accurate for forked destinations.
This commit is contained in:
Colby McHenry
2026-08-31 11:34:26 -05:00
parent 209a07e881
commit 6f4887db80
31 changed files with 3969 additions and 87 deletions
+32
View File
@@ -451,6 +451,38 @@ pages only, from files under a Next app's root; `<Link href>` and an internal `<
reads them and draws a dashed `navigates` edge from the component (`synthesizedBy: 'next-link'`, the site as `registeredAt`).
`app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens (`POST /api/users`, `ANY /api/users`), so the same
index is a web app: pages on this tab, endpoints in Entry points, and a page's Steps picture firing from its load.
React Router (`frameworks/react.ts` reads the routes, `frameworks/react-router.ts` the navigation, `react-router-synthesizer.ts`
the markup): `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` are the routes, already
named `:param` the way this table wants them; `history.push` / `.replace`, `useNavigate`'s `navigate`, a data router's
`router.navigate` and a loader's `redirect` read their argument with the same Expo readers, and `<Link to>` / `<NavLink to>` /
`<Navigate to>` / `<LinkContainer to>` are markup a synthesizer reads (`synthesizedBy: 'react-router-link'`). Two things are the
app's, not the router's: the receiver has to name a router, because an unqualified `push` is an array's; and the app ROOT a call
is read from is everything before the declaring file's `src/` (proshop's routes are in `frontend/src/App.js`, its screens in
`frontend/src/screens/`). An optional parameter is registered twice — `/cart/:id?` answers `/cart` and `/cart/5` — because the
matcher pairs a route with an href of the same length. A nested route's relative path and a splat are not destinations.
Vue Router (`frameworks/vue-router.ts`, `vue-router-synthesizer.ts`): the routes are `createRouter({ routes: [...] })`, walked as
objects rather than pattern-matched — a `name` is written ABOVE the `path` it belongs to, so a window around each `path` hands an
entry its predecessor's name — and bound to the view each names by a `calls` edge, because a `references` candidate list is
filtered to the ref's own language family and a `.js` router config can never name a `.vue` component. Navigation is usually a
NAME (`router.push({ name: 'profile' })`, `:to="{ name }"`), which no other framework here does, so the table carries a `byName`
index built by re-reading the config files its own route nodes came from; a `{ path }` object and a bare string fall back to the
shared href readers. SvelteKit (`frameworks/sveltekit-router.ts`, `sveltekit-synthesizer.ts`): only a `+page.svelte` is a route
(a `+layout` and a `+error` sit at a page's address without being one); `goto` takes its path first and `redirect(status, path)`
second, the one framework here that does; `<a href>` is the link component. A route is joined to the `+page.svelte`
that serves it (`sveltekit-page`), because a SvelteKit route is derived from a file PATH and its component has no name of its own
to reference — every page file's component is called `+page` — so the match is the file, not a name; without it a page had no
body and opened as a lone box. The page is in turn joined to the `+page.server.js` beside it by `callback-synthesizer.ts`'s
`svelteKitLoadEdges` — a `calls` edge to its load and to each form action, because the framework joins those two by the file
system and not by a call, and a page's own auth guard is written in its loader.
TanStack Router (`frameworks/tanstack-router.ts`, `tanstack-router-synthesizer.ts`): routes come from `createFileRoute('/x/$id')`
(the whole path as a literal) and from `createRoute({ path, getParentRoute })` composed up its parent chain within the file;
`$id` normalises to `:id`, a `_pathless` segment and a `(group)` are not in the URL, and neither a `__root` route nor a file that
renders an `<Outlet/>` is a page — the index beside it is. Its `to` is the route PATTERN with the values in `params`, so a
destination is normalised the way a route NAME is rather than read as a URL, and `navigate` / `redirect` take it under a `to`
key. **Every table above is per-app** (`RootedRouteTable`, `routesForFile`): one table for a whole repository is wrong the moment
it holds two apps, because each has a `/` and a `/login` and the first indexed claims the address — measured at 82% of
navigations pointing into a different app on a 477-app monorepo. The `roots` list decides only whether to resolve; the per-root
split decides which app's routes to match, longest root first.
### 3.13 Steps (`#/steps?anchor=<id>` | `?symbol=<name>`, `&depth=`)
What happens from an anchor — a screen, a handler, any symbol — drawn with the Screens view's machinery (§3.12's