From cfb26f59992ebebf07fd9555e6f2124366d0e0cd Mon Sep 17 00:00:00 2001 From: AleksanderBodurri Date: Sun, 9 Nov 2025 22:31:42 -0500 Subject: [PATCH] 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. --- .../src/lib/router-tree.spec.ts | 16 ++--- .../src/lib/router-tree.ts | 68 +++++++++++++++++-- devtools/src/app/BUILD.bazel | 1 + devtools/src/app/demo-app/BUILD.bazel | 1 + .../src/app/demo-app/auxiliary/BUILD.bazel | 20 ++++++ .../auxiliary/auxiliary.component.html | 1 + .../auxiliary/auxiliary.component.scss | 0 .../demo-app/auxiliary/auxiliary.component.ts | 15 ++++ .../src/app/demo-app/demo-app.component.ts | 3 +- devtools/src/app/demo-app/demo-app.routes.ts | 6 ++ .../demo-app/todo/about/about.component.ts | 19 ++++-- .../app/demo-app/todo/about/about.routes.ts | 10 ++- .../app/demo-app/todo/app-todo.component.html | 15 ++++ .../app/demo-app/todo/app-todo.component.scss | 24 +++++++ .../app/demo-app/todo/app-todo.component.ts | 14 +++- devtools/src/app/demo-app/todo/app.module.ts | 35 +++++++++- 16 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 devtools/src/app/demo-app/auxiliary/BUILD.bazel create mode 100644 devtools/src/app/demo-app/auxiliary/auxiliary.component.html create mode 100644 devtools/src/app/demo-app/auxiliary/auxiliary.component.scss create mode 100644 devtools/src/app/demo-app/auxiliary/auxiliary.component.ts diff --git a/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts b/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts index 5c6e6a3b078..77cd46d84a6 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/router-tree.spec.ts @@ -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()', }, ], diff --git a/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts b/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts index ac9475dd57d..7a3c182c1d7 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts @@ -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 = new Set(), +): Set { + // 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 containing all currently active route configurations + * ``` + */ +export function getActiveRouteConfigs(router: Router): Set { + 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, ): 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) { diff --git a/devtools/src/app/BUILD.bazel b/devtools/src/app/BUILD.bazel index f6f47d3805c..af23ff006f0 100644 --- a/devtools/src/app/BUILD.bazel +++ b/devtools/src/app/BUILD.bazel @@ -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", ], ) diff --git a/devtools/src/app/demo-app/BUILD.bazel b/devtools/src/app/demo-app/BUILD.bazel index dafdc590d57..86b66736b16 100644 --- a/devtools/src/app/demo-app/BUILD.bazel +++ b/devtools/src/app/demo-app/BUILD.bazel @@ -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", ], ) diff --git a/devtools/src/app/demo-app/auxiliary/BUILD.bazel b/devtools/src/app/demo-app/auxiliary/BUILD.bazel new file mode 100644 index 00000000000..80a1dc1542a --- /dev/null +++ b/devtools/src/app/demo-app/auxiliary/BUILD.bazel @@ -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", + ], +) diff --git a/devtools/src/app/demo-app/auxiliary/auxiliary.component.html b/devtools/src/app/demo-app/auxiliary/auxiliary.component.html new file mode 100644 index 00000000000..ac8debef23b --- /dev/null +++ b/devtools/src/app/demo-app/auxiliary/auxiliary.component.html @@ -0,0 +1 @@ +

Auxiliary route is working!

diff --git a/devtools/src/app/demo-app/auxiliary/auxiliary.component.scss b/devtools/src/app/demo-app/auxiliary/auxiliary.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/devtools/src/app/demo-app/auxiliary/auxiliary.component.ts b/devtools/src/app/demo-app/auxiliary/auxiliary.component.ts new file mode 100644 index 00000000000..e6292fe6907 --- /dev/null +++ b/devtools/src/app/demo-app/auxiliary/auxiliary.component.ts @@ -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 {} diff --git a/devtools/src/app/demo-app/demo-app.component.ts b/devtools/src/app/demo-app/demo-app.component.ts index 9c38ace1403..cc549c50f10 100644 --- a/devtools/src/app/demo-app/demo-app.component.ts +++ b/devtools/src/app/demo-app/demo-app.component.ts @@ -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, ], }) diff --git a/devtools/src/app/demo-app/demo-app.routes.ts b/devtools/src/app/demo-app/demo-app.routes.ts index b29c4029e95..7862d9cbf7a 100644 --- a/devtools/src/app/demo-app/demo-app.routes.ts +++ b/devtools/src/app/demo-app/demo-app.routes.ts @@ -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(() => { diff --git a/devtools/src/app/demo-app/todo/about/about.component.ts b/devtools/src/app/demo-app/todo/about/about.component.ts index fee468e0749..2c4297d2ff6 100644 --- a/devtools/src/app/demo-app/todo/about/about.component.ts +++ b/devtools/src/app/demo-app/todo/about/about.component.ts @@ -7,16 +7,23 @@ */ import {Component} from '@angular/core'; -import {RouterLink} from '@angular/router'; @Component({ selector: 'app-about', + standalone: true, template: ` - About component - Home - Home - Home +

About Component

+

This is the default about component (no guard).

`, - imports: [RouterLink], }) export class AboutComponent {} + +@Component({ + selector: 'app-protected-about', + standalone: true, + template: ` +

Protected About Component

+

This component is rendered when the canMatch guard allows access.

+ `, +}) +export class ProtectedAboutComponent {} diff --git a/devtools/src/app/demo-app/todo/about/about.routes.ts b/devtools/src/app/demo-app/todo/about/about.routes.ts index 55d11e08314..608fefaa47b 100644 --- a/devtools/src/app/demo-app/todo/about/about.routes.ts +++ b/devtools/src/app/demo-app/todo/about/about.routes.ts @@ -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, + }, +]; diff --git a/devtools/src/app/demo-app/todo/app-todo.component.html b/devtools/src/app/demo-app/todo/app-todo.component.html index 95ae8a85297..72df6a16fa3 100644 --- a/devtools/src/app/demo-app/todo/app-todo.component.html +++ b/devtools/src/app/demo-app/todo/app-todo.component.html @@ -4,6 +4,21 @@ Routes +
+ +

+ Toggle this to switch between About routes (with canMatch guards) +

+
+ diff --git a/devtools/src/app/demo-app/todo/app-todo.component.scss b/devtools/src/app/demo-app/todo/app-todo.component.scss index b874023493f..52f9cdd0fc2 100644 --- a/devtools/src/app/demo-app/todo/app-todo.component.scss +++ b/devtools/src/app/demo-app/todo/app-todo.component.scss @@ -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; +} diff --git a/devtools/src/app/demo-app/todo/app-todo.component.ts b/devtools/src/app/demo-app/todo/app-todo.component.ts index b16a451eb7f..5b45e6315a7 100644 --- a/devtools/src/app/demo-app/todo/app-todo.component.ts +++ b/devtools/src/app/demo-app/todo/app-todo.component.ts @@ -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: () => { diff --git a/devtools/src/app/demo-app/todo/app.module.ts b/devtools/src/app/demo-app/todo/app.module.ts index 3dfdf8e4015..76ff72590c2 100644 --- a/devtools/src/app/demo-app/todo/app.module.ts +++ b/devtools/src/app/demo-app/todo/app.module.ts @@ -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(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),