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:
@@ -18,6 +18,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
- **A Next.js app lands on the Screens tab like a mobile app.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are screens bound to the component they export; `<Link href>`, an internal `<a href>`, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` in a server action or a page, and the middleware's `NextResponse.redirect(new URL('/login', req.url))` are the transitions between them — each attributed back to the page it starts on with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, `pages/api/*` handlers are `ANY /api/…`, and a page's Steps picture fires from its load (`FIRES FROM page load · /users`), draws the data it reads, the handlers it wires, the server actions it crosses to and the pages it leads to as boundaries. A response's status written as `{ status: 201 }` is read too. Re-index after upgrading.
|
||||
|
||||
- **TanStack Router apps land on the Screens tab too.** Routes are read both ways a TanStack app declares them: file-based, where `createFileRoute('/posts/$postId')` carries the whole path as a literal, and code-based, where each `createRoute({ path, getParentRoute })` names a fragment that is composed through its parent into `/posts/$postId`. `navigate({ to })` from `useNavigate`, a thrown `redirect({ to })` from a loader or `beforeLoad`, and `<Link to>` / `<Navigate to>` are the transitions between them. TanStack is the one router here whose destination is the route PATTERN rather than a filled address — `<Link to="/posts/$postId" params={{ postId }}>` names the route and passes the values beside it — so a destination is read as a pattern and matched against the route it names. Addresses that are not pages are left off the map: a `_auth` segment is a pathless layout and never appears in the URL, a `(group)` folder is invisible, a `__root` route wraps everything without being a page, and a file that renders an `<Outlet />` is the layout around an address while the index route beside it is the page at it. A computed `to`, a pattern no route serves, and a `navigate({ search })` that only changes the query are left out rather than guessed. Re-index after upgrading.
|
||||
|
||||
- **Vue Router and SvelteKit apps land on the Screens tab too.** Both drew their screens as islands with no transitions, so the tab stayed hidden; now the navigation between pages is read for each. **Vue:** the routes are read out of `createRouter({ routes: [...] })` — path, name, and the view each entry names, including a lazy `component: () => import('@/views/Login')` — and `router.push` / `router.replace` / `$router.push`, Nuxt's `navigateTo`, and `<router-link>` / `<RouterLink>` / `<NuxtLink>` are the transitions. Vue apps usually navigate by route NAME rather than by path, so `router.push({ name: 'profile' })` and `:to="{ name: 'profile' }"` resolve by name, and `router.push({ path: '/', query })` by path. **SvelteKit:** `goto('/login')`, `redirect(303, '/article/' + slug)` from a load or a form action — whose destination is its *second* argument, after the status — and the plain `<a href="/login">` that is a link in a SvelteKit app. A SvelteKit page also opens with a body now — it is joined to the page file that serves it and to the `+page.server.js` beside it — so its Steps picture draws its loader's work, its form actions, and the auth guard the loader performs (`redirect(302, '/login')` under `if (!locals.user)`) as a transition to the sign-in page, with the condition on the arrow. A computed destination, a path or name nothing declares, a relative path in a nested route, and a conditional whose two arms go to different pages are left out rather than guessed. Re-index after upgrading.
|
||||
|
||||
- **A React Router app lands on the Screens tab too.** `<Route path='/payment' component={PaymentScreen}>` (v5), `<Route path='/payment' element={<PaymentScreen/>}>` (v6) and `createBrowserRouter([{ path, element }])` already named a project's screens; now the navigation between them is drawn as well. `history.push('/placeorder')` and `history.replace`, `navigate('/placeorder')` from `useNavigate`, `redirect()` in a loader or an action, and `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` each become a transition — attributed back to the screen it starts on, with the plumbing folded and the condition on the arrow, a link written in markup drawn dashed as an inferred hop. A route with an optional parameter (`/cart/:id?`) is reached by both `/cart` and `/cart/5`. Until now a React Router project's screens were drawn as islands with no transitions at all, and a screen's Steps picture left out every page it sends you to — a checkout step showed its saved payment method but not that it goes on to place the order. A computed destination (`history.push(redirect)`), a path no route serves, and a relative path inside a nested route are left out rather than guessed, and an ordinary `paths.push('/x')` on an array is never mistaken for navigation. Re-index after upgrading.
|
||||
|
||||
- **Double-click a box to go there.** On the Steps tab a double-click on any step starts the picture from it — the same as the panel's *Start here* — so an endpoint the page calls, or another screen drawn as a boundary, opens as its own chapter in one gesture; on the Screens tab a double-click on a screen opens what happens from it. A boundary's panel now says it is not entered rather than that nothing leaves it.
|
||||
|
||||
- **The Steps tab follows a web app across its tiers.** A page's `fetch('/api/users', { method: 'POST' })` (or an `axios` / `ky` / `got` / `$fetch` call, including one through a project instance made with `axios.create({ baseURL })`) now reaches the route that serves it in the same index — drawn as a crossing to the server (`⇢ POST /api/users`) with the handler named on the box and the registration site in the panel, a boundary by default and entered with *Continue through*, so the picture reads page → handler → the endpoint → its database write → its response. A job put on a BullMQ / Bull queue lands on the `@Process` method, `Worker` or `queue.process` handler that consumes it; a NestJS `EventEmitter2` event lands on its `@OnEvent` listeners (globs included); a socket message crosses from a client's `socket.emit` to the gateway's `@SubscribeMessage` and back from the server's `emit` to the component that registered `socket.on`; and a Next.js server action called from a client file is a crossing to the server by its `'use server'` directive. Each of these is a synthesized hop — dashed, with where it was wired up — and `codegraph_explore`'s Flow section names them too. Only a literal path or event name pairs: a variable url, a path no route serves, or one two routes serve alike produce nothing. Re-index to pick the new edges up.
|
||||
@@ -34,6 +40,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- **A link written under a condition says so on the Screens tab.** A checkout stepper whose tabs are each enabled by their own prop, and a navbar whose admin links only render for an admin, both read as **always** — every transition written in markup was drawn with no condition at all, while the ones written as calls carried theirs. They are read the same way now: a store's checkout tabs say `step1` … `step4`, its navbar says `userInfo && userInfo.isAdmin` for the admin links and `!userInfo` for sign-in, and 59 of that store's 74 transitions carry the condition they actually run under, up from 20. A template language with no condition rules of its own still says nothing rather than guessing.
|
||||
|
||||
- **A link in markup no longer reads as a helper's return value.** `<Link to='/shipping'>` was labelled `return /shipping`, which in this picture means the destination came back from somewhere else and was inferred. It is written right there, so it now reads `link /shipping` — and an internal `<a href>` reads `a`. Only a destination that genuinely arrives from elsewhere still says `return`.
|
||||
|
||||
- **A link that goes to one of several places now draws all of them.** A destination written as a choice — `!isAdmin ? keyword ? \`/search/${keyword}/page/${x}\` : \`/page/${x}\` : \`/admin/productlist/${x}\`, which is how a paginator shared between a storefront and an admin list is written — used to draw nothing at all, because one edge carried one destination and picking an arm would have been a guess. Every arm is now its own transition, labelled with the path THAT arm takes, so a store's paginated addresses are on the map instead of sitting there unreachable. The same goes for `redirect(307, user ? \`/profile/@${user.username}\` : '/login')` in a loader, and for `router.push(cond ? '/a' : '/b')`. Arms that name the same route still make one transition, and an arm nothing can read still contributes nothing.
|
||||
|
||||
- **API endpoints are no longer drawn on the Screens tab.** A store's thirty Express endpoints sat beside its nineteen pages as boxes nothing navigates to and nothing leaves — in a picture that is only about navigation — and they stretched the row of unreachable pages hundreds of boxes wide. A screen is now a route named by its path alone; a route named with the method that reaches it (`GET /api/orders`, `POST /api/users/login`, `ANY /api/users`) is a request, not somewhere a user can be. Every route still appears on Entry points, which is the list of everything a request or a user can arrive at.
|
||||
|
||||
- **A screen you could reach but never leave.** Three separate things left a page's own navigation off the map, and a store's home and sign-in pages showed nothing leaving them. **One component, several addresses:** a screen rendered at more than one route — a listing page that is also the search and the paginated results — handed all of its navigation to whichever route happened to be written first, and the rest were drawn as dead ends; every address it serves now gets it. **A link written as a choice:** `<Link to={redirect ? \`/register?redirect=${redirect}\` : '/register'}>` is how a link that carries state is written, and markup was read by a weaker reader than calls were, so it saw nothing; both now use the same one. **A destination whose other half is unknowable:** `const redirect = location.search ? location.search.split('=')[1] : '/'` followed by `history.push(redirect)` is how every app sends a user on after signing in — the `/` is where it lands by default, and reading neither half lost the whole transition. Where both halves ARE readable and disagree, it is still a fork and still nothing.
|
||||
|
||||
- **A conditional inside a conditional no longer reads the wrong arm.** A paginator written `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` was split at the first `:` rather than the matching one, so an admin's page links pointed at the storefront's pagination. The arms are paired properly now, and a three-way choice — which is more destinations than one link can name — is left alone.
|
||||
|
||||
- **In a repository with several apps, a link no longer points into a different one.** Every app has a `/` and most have a `/login`, and the route table was built for the whole repository at once, so whichever app was indexed first claimed each address — a `<Link to="/posts">` in one app resolved to another app's `/posts`. Measured on a monorepo of 477 apps: **82% of navigations pointed at a route belonging to a different app**, and all of them now point within their own. Screens transitions are also no longer attributed to an unrelated page when several routes are declared in one file, as a code-based route tree or an Express router file is.
|
||||
|
||||
- **A SvelteKit layout is no longer a second screen at a page's address.** `+layout.svelte` and `+error.svelte` sit at the same path as the `+page.svelte` beside them and were each indexed as a route, so one address appeared in the index two and three times over. Only a page is a route now.
|
||||
|
||||
- **A framework whose package lives in a subfolder is detected again.** In a project that keeps its dependencies one level down — a `frontend/` and a `backend/`, or an `apps/web/` — the framework check ran once before any file had been indexed, found no folders to look in, and remembered that empty answer for the rest of the run. Every React, React Router and Next.js behaviour that depends on knowing the framework is there silently did nothing for those projects.
|
||||
|
||||
- **A server action, or any handler written inside a wrapper, draws what it really does.** `const signIn = validatedAction(schema, async (data) => { … })` showed one call out of nine, because the constant held a reference to its schema and that counted as having a body of its own. Its picture is now whole — the lookup, the early returns, the `Promise.all`, the redirect.
|
||||
|
||||
- **A call is no longer followed to a same-named method of its own class.** `crypto.createHash('sha256').update(…)` inside a service that happens to have an `update` method was followed into that method, so a login endpoint read as though it updated the user, extra replies and all. A method of your own class is written `this.update(…)`; a receiver that is not `this` now ends the walk instead of guessing.
|
||||
|
||||
Reference in New Issue
Block a user