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),