feat(devtools): use router state for active route detection

Replace URL-based active route detection with direct traversal of the ActivatedRoute tree.

This solution is more reliable than the previous approach because it directly compares router tree configuration objects against the active router instance state with router.routerState.
This commit is contained in:
AleksanderBodurri 2025-11-09 22:31:42 -05:00 committed by Andrew Kushnir
parent 55be477979
commit cfb26f5999
16 changed files with 222 additions and 26 deletions

View file

@ -126,7 +126,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': true,
'isLazy': false,
'isActive': undefined,
'isActive': false,
},
{
'component': 'component-two',
@ -141,7 +141,7 @@ describe('parseRoutes', () => {
'data': [{'key': 'name', 'value': 'component-two'}],
'isAux': false,
'isLazy': false,
'isActive': undefined,
'isActive': false,
'children': [
{
'component': 'component-two-one',
@ -156,7 +156,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': false,
'isLazy': false,
'isActive': undefined,
'isActive': false,
},
{
'component': 'component-two-two',
@ -171,7 +171,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': false,
'isLazy': false,
'isActive': undefined,
'isActive': false,
},
],
},
@ -187,7 +187,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': false,
'isLazy': true,
'isActive': undefined,
'isActive': false,
},
{
'component': 'no-name-route',
@ -201,7 +201,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': false,
'isLazy': false,
'isActive': undefined,
'isActive': false,
'redirectTo': 'redirectTo',
},
{
@ -216,7 +216,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': false,
'isLazy': false,
'isActive': undefined,
'isActive': false,
'redirectTo': '[Function]',
},
{
@ -231,7 +231,7 @@ describe('parseRoutes', () => {
'data': [],
'isAux': false,
'isLazy': false,
'isActive': undefined,
'isActive': false,
'redirectTo': 'redirectResolver()',
},
],

View file

@ -7,7 +7,7 @@
*/
import {Route} from '../../../protocol';
import type {Route as AngularRoute} from '@angular/router';
import type {Route as AngularRoute, ActivatedRoute} from '@angular/router';
export type RoutePropertyType = RouteGuard | 'providers' | 'component' | 'redirectTo' | 'title';
@ -18,15 +18,64 @@ const routeGuards = ['canActivate', 'canActivateChild', 'canDeactivate', 'canMat
type Routes = any;
type Router = any;
/**
* Recursively traverses the ActivatedRoute tree and collects all routeConfig objects.
* @param activatedRoute - The ActivatedRoute to start traversal from
* @param activeRoutes - Set to collect active Route configuration objects
* @returns Set of active Route configuration objects
*/
function collectActiveRouteConfigs(
activatedRoute: ActivatedRoute,
activeRoutes: Set<AngularRoute> = new Set(),
): Set<AngularRoute> {
// Get the routeConfig for this ActivatedRoute
const routeConfig = activatedRoute.routeConfig;
if (routeConfig) {
activeRoutes.add(routeConfig);
}
// Recursively process all children
const children = activatedRoute.children || [];
for (const child of children) {
collectActiveRouteConfigs(child, activeRoutes);
}
return activeRoutes;
}
/**
* Gets the set of currently active Route configuration objects from the router state.
* This function synchronously reads the current router state without waiting for navigation events.
*
* @param router - The Angular Router instance
* @returns A Set containing all Route configuration objects that are currently active
*
* @example
* ```ts
* const activeRoutes = getActiveRouteConfigs(router);
* // activeRoutes is a Set<Route> containing all currently active route configurations
* ```
*/
export function getActiveRouteConfigs(router: Router): Set<AngularRoute> {
const rootActivatedRoute = router.routerState?.root;
if (!rootActivatedRoute) {
return new Set();
}
return collectActiveRouteConfigs(rootActivatedRoute);
}
export function parseRoutes(router: Router): Route {
const currentUrl = router.stateManager?.routerState?.snapshot?.url;
const rootName = 'App Root';
const rootChildren = router.config;
// Get the set of active Route configuration objects from the router state
const activeRouteConfigs = getActiveRouteConfigs(router);
const root: Route = {
component: rootName,
path: rootName,
children: rootChildren ? assignChildrenToParent(null, rootChildren, currentUrl) : [],
children: rootChildren ? assignChildrenToParent(null, rootChildren, activeRouteConfigs) : [],
isAux: false,
isLazy: false,
isActive: true, // Root is always active.
@ -52,7 +101,7 @@ function getProviderName(child: any): string[] {
function assignChildrenToParent(
parentPath: string | null,
children: Routes,
currentUrl: string,
activeRouteConfigs: Set<AngularRoute>,
): Route[] {
return children.map((child: AngularRoute) => {
const childName = childRouteName(child);
@ -66,8 +115,9 @@ function assignChildrenToParent(
const isAux = Boolean(child.outlet);
const isLazy = Boolean(child.loadChildren || child.loadComponent);
const pathWithoutParams = routePath.split('/:')[0];
const isActive = currentUrl?.startsWith(pathWithoutParams);
// Check if this route configuration object is in the active routes set
// This is the direct reference to the Route object from router.config
const isActive = activeRouteConfigs.has(child);
const routeConfig: Route = {
pathMatch: child.pathMatch,
@ -93,7 +143,11 @@ function assignChildrenToParent(
}
if (childDescendents) {
routeConfig.children = assignChildrenToParent(routeConfig.path, childDescendents, currentUrl);
routeConfig.children = assignChildrenToParent(
routeConfig.path,
childDescendents,
activeRouteConfigs,
);
}
if (child.data) {

View file

@ -26,6 +26,7 @@ ng_project(
"//devtools/src:demo_application_environment",
"//devtools/src:demo_application_operations",
"//devtools/src/app/demo-app",
"//devtools/src/app/demo-app/auxiliary",
"//devtools/src/app/devtools-app",
],
)

View file

@ -60,6 +60,7 @@ ng_project(
"//devtools/projects/ng-devtools-backend",
"//devtools/src:communication",
"//devtools/src:zone-unaware-iframe_message_bus",
"//devtools/src/app/demo-app/auxiliary",
"//devtools/src/app/demo-app/todo",
],
)

View file

@ -0,0 +1,20 @@
load("//devtools/tools:defaults.bzl", "ng_project", "sass_binary")
package(default_visibility = ["//visibility:public"])
sass_binary(
name = "auxiliary_component_styles",
src = "auxiliary.component.scss",
)
ng_project(
name = "auxiliary",
srcs = ["auxiliary.component.ts"],
angular_assets = [
"auxiliary.component.html",
":auxiliary_component_styles",
],
deps = [
"//:node_modules/@angular/core",
],
)

View file

@ -0,0 +1 @@
<p>Auxiliary route is working!</p>

View file

@ -0,0 +1,15 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Component} from '@angular/core';
@Component({
selector: 'app-auxiliary',
templateUrl: './auxiliary.component.html',
})
export class AuxiliaryComponent {}

View file

@ -26,7 +26,7 @@ import {
import {ZippyComponent} from './zippy.component';
import {HeavyComponent} from './heavy.component';
import {SamplePropertiesComponent} from './sample-properties.component';
import {RouterOutlet} from '@angular/router';
import {RouterOutlet, RouterModule} from '@angular/router';
import {CookieRecipe} from './cookies.component';
// structual directive example
@ -57,6 +57,7 @@ export class StructuralDirective {
HeavyComponent,
SamplePropertiesComponent,
RouterOutlet,
RouterModule,
CookieRecipe,
],
})

View file

@ -16,6 +16,7 @@ import {DEVTOOLS_BACKEND_URI, DEVTOOLS_FRONTEND_URI} from '../../communication';
import {DemoAppComponent} from './demo-app.component';
import {ZippyComponent} from './zippy.component';
import {AuxiliaryComponent} from './auxiliary/auxiliary.component';
export const DEMO_ROUTES: Routes = [
{
@ -26,6 +27,11 @@ export const DEMO_ROUTES: Routes = [
path: '',
loadChildren: () => import('./todo/app.module').then((m) => m.AppModule),
},
{
path: 'aux',
component: AuxiliaryComponent,
outlet: 'aux',
},
],
providers: [
provideEnvironmentInitializer(() => {

View file

@ -7,16 +7,23 @@
*/
import {Component} from '@angular/core';
import {RouterLink} from '@angular/router';
@Component({
selector: 'app-about',
standalone: true,
template: `
About component
<a [routerLink]="">Home</a>
<a [routerLink]="">Home</a>
<a [routerLink]="">Home</a>
<h1>About Component</h1>
<p>This is the default about component (no guard).</p>
`,
imports: [RouterLink],
})
export class AboutComponent {}
@Component({
selector: 'app-protected-about',
standalone: true,
template: `
<h1>Protected About Component</h1>
<p>This component is rendered when the canMatch guard allows access.</p>
`,
})
export class ProtectedAboutComponent {}

View file

@ -8,7 +8,7 @@
import {Routes} from '@angular/router';
import {AboutComponent} from './about.component';
import {AboutComponent, ProtectedAboutComponent} from './about.component';
export const ABOUT_ROUTES: Routes = [
{
@ -17,3 +17,11 @@ export const ABOUT_ROUTES: Routes = [
component: AboutComponent,
},
];
export const PROTECTED_ABOUT_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
component: ProtectedAboutComponent,
},
];

View file

@ -4,6 +4,21 @@
<a routerLink="/demo-app/todos/routes">Routes</a>
</nav>
<div class="guard-toggle-container">
<label class="guard-toggle-label">
<input
type="checkbox"
[checked]="allowGuard()"
(change)="toggleAllowGuard()"
class="guard-toggle-input"
/>
<span>Allow Guard: {{ allowGuard() ? 'ON' : 'OFF' }}</span>
</label>
<p class="guard-toggle-description">
Toggle this to switch between About routes (with canMatch guards)
</p>
</div>
<button class="dialog-open-button" (click)="openDialog()">Open dialog</button>
<router-outlet></router-outlet>

View file

@ -13,3 +13,27 @@ nav {
padding: 10px;
margin-right: 20px;
}
.guard-toggle-container {
margin: 10px 0;
padding: 10px;
background-color: #f0f0f0;
border-radius: 4px;
}
.guard-toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.guard-toggle-input {
cursor: pointer;
}
.guard-toggle-description {
margin: 5px 0 0 0;
font-size: 12px;
color: #666;
}

View file

@ -10,7 +10,8 @@ import {afterRenderEffect, Component, inject, Injectable, signal, viewChild} fro
import {MatDialog} from '@angular/material/dialog';
import {DialogComponent} from './dialog.component';
import {RouterOutlet} from '@angular/router';
import {Router, RouterOutlet} from '@angular/router';
import {AllowGuardService} from './app.module';
@Injectable()
export class MyServiceA {}
@ -29,11 +30,22 @@ export class AppTodoComponent {
viewChildWillThrowAnError = viewChild.required('thisSignalWillThrowAnError');
routerOutlet = viewChild(RouterOutlet);
readonly dialog = inject(MatDialog);
readonly router = inject(Router);
readonly allowGuardService = inject(AllowGuardService);
/** Only for Signal graph purposes */
counter = signal(0);
// Expose the signal from the service directly
readonly allowGuard = this.allowGuardService.allowGuard;
toggleAllowGuard(): void {
this.allowGuardService.toggle();
const currentUrl = this.router.url;
this.router.navigateByUrl(currentUrl, {onSameUrlNavigation: 'reload'});
}
// tslint:disable-next-line:require-internal-with-underscore
_ = afterRenderEffect({
earlyRead: () => {

View file

@ -6,12 +6,36 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {NgModule} from '@angular/core';
import {inject, Injectable, NgModule, signal} from '@angular/core';
import {MatDialogModule} from '@angular/material/dialog';
import {provideRouter, RouterLink, RouterOutlet} from '@angular/router';
import {
ActivatedRouteSnapshot,
CanActivateFn,
provideRouter,
RouterLink,
RouterOutlet,
} from '@angular/router';
import {AppTodoComponent} from './app-todo.component';
/**
* Service to manage the allowGuard state for testing router tree visualization.
*/
@Injectable({providedIn: 'root'})
export class AllowGuardService {
readonly allowGuard = signal<boolean>(false);
toggle(): void {
this.allowGuard.update((value) => !value);
}
}
export const canMatchGuard: CanActivateFn = () => {
const allowGuardService = inject(AllowGuardService);
const allowed = allowGuardService.allowGuard();
return allowed;
};
@NgModule({
declarations: [AppTodoComponent],
imports: [MatDialogModule, RouterLink, RouterOutlet],
@ -25,6 +49,13 @@ import {AppTodoComponent} from './app-todo.component';
path: 'app',
loadChildren: () => import('./home/home.routes').then((m) => m.HOME_ROUTES),
},
{
path: 'about',
loadChildren: () =>
import('./about/about.routes').then((m) => m.PROTECTED_ABOUT_ROUTES),
title: 'Protected About Route',
canMatch: [canMatchGuard],
},
{
path: 'about',
loadChildren: () => import('./about/about.routes').then((m) => m.ABOUT_ROUTES),