diff --git a/README.md b/README.md index 1914b3a..ecd0704 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Inspect Angular component trees, signals, dependency injection, and routes — a - **Component inspector** — discover components, inputs, outputs, and source files; view injected providers per component - **Signal graph** — visualize signal, computed, linkedSignal, effect nodes and their dependency edges (Angular 19+) - **DI inspector** — browse the injector hierarchy (element and environment) with providers at each level (Angular 17+) -- **Route inspector** — list registered routes from source +- **Route inspector** — the live route, every navigation as a full story (who started it, redirects, per-phase timing, which guard or resolver decided it, errors explained), the live route config with URL testing, router setup, route lint, and actions to navigate, replay, probe and abort - **NgRx Store inspector** — detect `@ngrx/store` (actions, reducers, effects, selectors) and `@ngrx/signals` (`signalStore`, `signalState`, `signalMethod`) patterns from source; live state & action log via Redux DevTools protocol - **Forms inspector** — every form on the page (Signal Forms, reactive and template-driven) with each field's value, status, touched/dirty state and readable errors, plus a timeline of recent changes; hover a field to highlight its input - **Build metadata** — Angular version, TypeScript version, SSR status @@ -101,6 +101,14 @@ MCP clients see these with an underscore, as `ng-devtools_get-routes`. | `ng-devtools:get-ngrx-store` | Scan source for NgRx store patterns | | `ng-devtools:inspect-forms` | Forms on the page with every field's state and errors | | `ng-devtools:explain-form-invalid` | Which fields make a form invalid, and why | +| `ng-devtools:inspect-route` | The current route with params, data, guards and resolvers | +| `ng-devtools:explain-navigation` | Recent navigations and why each succeeded or not | +| `ng-devtools:list-routes` | Live route config; match a URL; audit guard protection | +| `ng-devtools:lint-routes` | Route config mistakes, with fixes | +| `ng-devtools:router-config` | Router options, features and strategies in effect | +| `ng-devtools:export-navigation` | Markdown repro of a navigation | +| `ng-devtools:explain-render-mode` | ServerRoute and render mode for a URL | +| `ng-devtools:navigate` | Navigate, abort, replay, probe, instrument (dev only) | #### Forms @@ -113,6 +121,33 @@ The Forms tab and the forms tools read Signal Forms, reactive forms and template Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Values of password fields, fields with a password, one-time-code or credit-card `autocomplete`, and fields whose name looks secret (password, token, card, cvv and similar) are replaced with `[redacted]`. Other values are sent as they are, so keep real credentials out of forms you inspect, and don't expose the dev server beyond localhost. +#### Router + +The Routes tab and the router tools read the running app's Router, in development builds only. The Router is found through the debug helper `provideRouter()` publishes, or through the injector for `RouterModule.forRoot()` apps. Without debug utils (a production build) only navigation events are available, and the Setup view says so. + +The Routes tab has five views: + +- **Current**: the URL (and the browser URL when they differ), the navigation in flight with an Abort button, each active route with its component, params and data and where each value comes from (own, inherited, static or resolved), the route title and whether it is inherited, and the outlet tree with the inputs the router binds. +- **Navigations**: every navigation as one story: where it came from, who started it (a RouterLink, the code that called `navigate`, back/forward), extras, redirect chains and loops, a phase bar (recognize, guards, resolve, activate), guards and resolvers, lazy loads, reused components, HTTP requests, scroll, the title afterwards, router warnings, and the cancel or error reason. Turn on "Record each guard and resolver" to see each one's verdict and time (for example `authGuard returned UrlTree /login`). Replay a navigation, copy a markdown repro, or export the list as JSON. +- **Routes**: the live route config with lazy children merged in once they load and the active branch marked. Test a URL to predict which route matches it (or the nearest ones), probe it with the real matcher, navigate to any route (with its params), or read the routes of a lazy route that has not loaded. +- **Setup**: provideRouter or forRoot, effective options with set/default markers, enabled features, strategies, base href and hydration. +- **Lint**: route config mistakes (unreachable routes after `**`, a `:param` shadowing a literal, duplicate paths, empty-path redirects without `pathMatch: 'full'`, redirect cycles, deprecated class guards and `canLoad`, lazy chunks downloaded before a rejecting `canActivate`, missing or duplicate titles, param/input typos, `routerLinkActive` without `ariaCurrentWhenActive`, emails in URLs, return URLs taken from query params), each with a fix and whether Angular throws or stays silent. + +Components rendered by the router show the route and outlet in the Components tab. + +For agents: + +- `ng-devtools:explain-navigation` answers "why did this navigation not work" or "why was I redirected": pass `url` or `id` to narrow it, `limit` for more than the last 5, or `perf` for the slowest navigations and preloads. NG04xxx and related errors are explained. +- `ng-devtools:inspect-route` describes the route the page is on right now; pass `selector` (a component class, tag or link text) to see which route a component was rendered for or whether a link counts as active. +- `ng-devtools:list-routes` lists the live config with source files and example URLs; `match` predicts which route a URL hits, `audit` lists the guards that protect each page. +- `ng-devtools:lint-routes`, `ng-devtools:router-config` and `ng-devtools:export-navigation` give the lint findings, the setup and a repro. +- `ng-devtools:explain-render-mode` reads the workspace's `*.routes.server.ts` and says which render mode a URL gets. +- `ng-devtools:navigate` acts on the router: `navigate` (a relative URL, or a pattern with params), `abort`, `replay`, `probe` (runs the real matcher without navigating; it runs `canMatch` and may load lazy chunks), `instrument` and `resolve-lazy`. It only accepts same-origin relative URLs. + +Without instrumentation, the guards listed for a navigation are candidates (the `canDeactivate` guards of the page being left and the `canActivate`/`canActivateChild` guards of the target), because the router reports one result for all of them. Instrumentation wraps each guard and resolver in the live config to record its verdict; it is off by default and undone when turned off. A navigation that finished before the devtools connected is listed without timing or guard details. + +Query, matrix and fragment keys that look secret (token, password, api key, code, sig, session, jwt and similar), including inside encoded return URLs, JWTs, bearer tokens, long opaque tokens and route params with such names are replaced with `[redacted]` in URLs, params, data and messages. A secret route param is only known once the route is recognized or found in the config, so a navigation that fails before that (for example inside a lazy route that failed to load) can still show it in its URL. + #### Agent Resources | Resource | Content | @@ -122,6 +157,7 @@ Form values leave the page: they are sent to the devtools server, shown in the F | `ng-devtools:injector-tree` | DI injector hierarchy | | `ng-devtools:ngrx-store` | Live NgRx state & action log | | `ng-devtools:forms` | Live forms and recent changes | +| `ng-devtools:router` | Live route and navigations | ### Vite DevTools Dock diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index d30c2de..30a65e3 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -1,4 +1,4 @@ -import { Component, input, signal, effect } from '@angular/core'; +import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; @@ -10,6 +10,14 @@ interface ComponentInfo { isStandalone: boolean; } +interface OutletInfo { + outlet: string; + route?: string; + element?: string; + activated: boolean; + children?: OutletInfo[]; +} + interface ProviderEntry { token: string; source: string; @@ -48,6 +56,9 @@ interface ProviderEntry { >
Could not load the live router state.
+ } @else if (loading()) { +Loading the live router state…
+ } @else if (page(); as current) { + @if (pages().length > 1) { + + } +No page is reporting router state yet. Open the app in a browser.
+ } + `, + styles: ` + :host { + display: grid; + gap: 12px; + margin-bottom: 28px; + } + .muted { + color: #a1a1aa; + font-size: 13px; + } + .page-pick { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 13px; + color: #d4d4d8; + } + select { + max-width: 100%; + min-width: 0; + padding: 4px 8px; + background: #18181b; + border: 1px solid #52525b; + border-radius: 6px; + color: #e4e4e7; + } + .tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + border-bottom: 1px solid #27272a; + } + [role='tab'] { + padding: 6px 12px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: #a1a1aa; + cursor: pointer; + font-size: 13px; + } + [role='tab'][aria-selected='true'] { + color: #e4e4e7; + border-bottom-color: var(--accent); + } + [role='tab']:focus-visible, + select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .count { + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + background: #7f1d1d; + color: #fecaca; + font-size: 11px; + } + `, +}) +export class LiveRoute { + rpc = input
+ {{ snapshot.url }}
+
+ The browser shows {{ snapshot.browserUrl }}, not the router URL (skipLocationChange, browserUrl, a failed navigation or code that
+ changed history).
+
{{ pending.url }} (#{{ pending.id }})
+
+ {{ message() }}
+ } +{{ snapshot.queryParams | json }}
+ {{ snapshot.fragment }}
+ | Route | +Component | +Params | +Data | +Guards and resolvers | +
|---|---|---|---|---|
|
+ {{ row.depth === 0 && !row.route.path ? '(root)' : '/' + row.route.path }}
+ @if (row.route.outlet !== 'primary') {
+ {{ row.route.outlet }}
+ }
+ @if (row.route.lazy) {
+ lazy
+ }
+ @if (row.route.title) {
+
+ title {{ row.route.title
+ }}{{ row.route.ownTitle === false ? ' (inherited)' : '' }}
+
+ }
+ |
+ {{ row.route.component ?? '—' }} | +
+ @for (entry of entries(row.route.params); track entry[0]) {
+
+
+ } @empty {
+ —
+ }
+ {{ entry[0] }}: {{ entry[1] | json }}
+ @if (row.route.paramSources?.[entry[0]] === 'inherited') {
+ inherited
+ }
+ |
+
+ @for (entry of entries(row.route.data); track entry[0]) {
+
+
+ } @empty {
+ —
+ }
+ {{ entry[0] }}: {{ entry[1] | json }}
+ @if (row.route.dataSources?.[entry[0]]; as source) {
+ {{ source }}
+ }
+ |
+ + @for (guard of guardList(row.route); track guard) { + {{ guard }} + } + @for (resolver of row.route.resolvers ?? []; track resolver) { + resolve {{ resolver }} + } + @if (!guardList(row.route).length && !row.route.resolvers) { + — + } + | +
{{ row.outlet.component ?? '?' }} for
+ {{ row.outlet.route ?? '?' }}
+ } @else {
+ not activated
+ }
+ @if (row.outlet.detached) {
+ detached by reuse strategy
+ }
+ @for (bound of boundInputs(row.outlet); track bound.input) {
+ input {{ bound.input }} ← {{ bound.source }}
+ }
+ This page reports no Router.
+ } + `, + styles: ` + ${SHARED_STYLES} + :host { + display: grid; + gap: 12px; + } + .url code { + font-size: 14px; + color: var(--accent); + } + .note { + margin: 0; + padding: 8px 10px; + border-left: 3px solid #fef08a; + background: #27272a; + color: #e4e4e7; + font-size: 13px; + } + .pending { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 13px; + color: #fef08a; + } + .facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 12px; + margin: 0; + font-size: 13px; + } + dt { + color: #a1a1aa; + } + dd { + margin: 0; + color: #e4e4e7; + } + .path { + font-family: monospace; + color: var(--accent); + white-space: nowrap; + } + .sub { + font-family: inherit; + color: #a1a1aa; + font-size: 12px; + white-space: normal; + } + .data { + max-width: 360px; + } + .outlets { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; + font-size: 13px; + color: #e4e4e7; + } + `, +}) +export class RouteCurrent { + page = input.requiredChecking…
+ } @else if (findings().length) { +{{ finding.rule }}
+ {{ finding.route }}
+ {{ finding.message }}
++ Fix: {{ finding.fix }} + (Angular + {{ + finding.angular === 'throws' + ? 'throws' + : finding.angular === 'warns' + ? 'warns' + : 'does not warn' + }}) +
+No route config problems found.
+ } + `, + styles: ` + ${SHARED_STYLES} + :host { + display: grid; + gap: 10px; + } + .toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + } + .findings { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; + } + .findings li { + padding: 10px; + border: 1px solid #27272a; + border-radius: 6px; + font-size: 13px; + color: #e4e4e7; + } + .head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .route { + color: var(--accent); + } + p { + margin: 6px 0 0; + } + .fix { + color: #d4d4d8; + } + `, +}) +export class RouteLint { + page = input.required+ Events-only mode: this build has no debug utils (production build or unusual setup), so + the route config, lint and actions are limited. +
+ } +{{ setup.baseHref }}
+ | Option | +Value | +Source | +
|---|---|---|
+ {{ option.name }}
+ |
+
+ {{ option.value }}
+ |
+ + {{ + option.set ? 'set' : 'default' + }} + | +
{{ strategy[1] }}
+ The page has not reported its router setup yet.
+ } + `, + styles: ` + ${SHARED_STYLES} + :host { + display: grid; + gap: 12px; + } + .note { + margin: 0; + padding: 8px 10px; + border-left: 3px solid #fef08a; + background: #27272a; + color: #e4e4e7; + font-size: 13px; + } + .facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 12px; + margin: 0; + font-size: 13px; + } + dt { + color: #a1a1aa; + } + dd { + margin: 0; + color: #e4e4e7; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + `, +}) +export class RouteSetup { + page = input.required{{ message() }}
+ } + + @if (items().length) { + + } @else { ++ No navigations since DevTools connected; earlier ones are not visible. Click a link in the + app. +
+ } + `, + styles: ` + ${SHARED_STYLES} + :host { + display: grid; + gap: 10px; + } + .toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: center; + font-size: 13px; + color: #e4e4e7; + } + .check { + display: flex; + gap: 6px; + align-items: center; + } + .legend { + display: flex; + gap: 12px; + font-size: 12px; + color: #a1a1aa; + } + .legend i { + display: inline-block; + width: 10px; + height: 10px; + margin-right: 4px; + border-radius: 2px; + } + .navs { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; + } + .navs > li { + padding: 10px; + border: 1px solid #27272a; + border-radius: 6px; + font-size: 13px; + } + .head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + time { + color: #a1a1aa; + font-size: 12px; + } + .bar { + display: flex; + height: 6px; + margin: 8px 0 4px; + border-radius: 3px; + overflow: hidden; + background: #27272a; + } + .bar span { + display: block; + min-width: 2px; + } + .details { + display: grid; + grid-template-columns: max-content 1fr; + gap: 3px 12px; + margin: 6px 0 0; + font-size: 12px; + } + dt { + color: #a1a1aa; + } + dd { + margin: 0; + color: #e4e4e7; + overflow-wrap: anywhere; + } + .reason, + .bad { + color: #fecaca; + } + .actions { + display: flex; + gap: 8px; + margin-top: 8px; + } + `, +}) +export class RouteTimeline { + page = input.required{{ result.params | json }}
+ }
+ } @else {
+ Matches no route (NG04002).
+ @if (result.nearest.length) {
+ Nearest: {{ result.nearest.join(', ') }}
+ }
+ }
+ @for (note of result.notes; track note) {
+ {{ message() }}
+ } + + + @if (!page().config) { ++ {{ + page().setup?.mode === 'events-only' + ? 'This build has no debug utils, so the live config cannot be read.' + : 'The page has not reported its route config yet.' + }} +
+ } @else { ++ Generation {{ page().generation }} · {{ rows().length }} route(s). Lazy routes show their + children once loaded. +
+| Path | +Target | +Guards and resolvers | +Title | +Actions | +
|---|---|---|---|---|
| + {{ row.node.fullPath }} + @if (isActive(row.node)) { + active + } + @if (row.node.lazy) { + lazy {{ row.node.lazy }} + } + @if (row.node.outlet) { + outlet {{ row.node.outlet }} + } + | +
+ @if (row.node.redirectTo !== undefined) {
+ redirect → {{ row.node.redirectTo }}
+ } @else {
+ {{
+ row.node.component ??
+ (row.node.lazy === 'unloaded' ? 'lazy, not loaded yet' : row.node.kind)
+ }}
+ }
+ |
+ + @for (guard of guardList(row.node); track guard) { + {{ guard }} + } + @for (resolver of row.node.resolvers ?? []; track resolver) { + resolve {{ resolver }} + } + | +{{ row.node.title ?? '' }} | ++ @if (canNavigate(row.node)) { + @for (param of params(row.node); track param) { + + } + + } + @if (row.node.kind === 'lazy' && row.node.lazy === 'unloaded') { + + } + | +