From 73796a9a5bb232f14cea54d1b4c44ea96ac12407 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:17:57 -0500 Subject: [PATCH] refactor(devtools): add matcher and runGuardsAndResolvers support in router viewer Enhance the Angular DevTools router viewer to display routes that use custom `matcher` functions and reflect the `runGuardsAndResolvers` configuration --- .../src/lib/router-tree.spec.ts | 59 +++++++++++++++++++ .../src/lib/router-tree.ts | 47 ++++++++++----- .../router-tree/router-tree.component.html | 43 +++++++++++--- .../router-tree/router-tree.component.ts | 24 ++++++-- .../projects/protocol/src/lib/messages.ts | 13 +++- .../app/demo-app/todo/routes/routes.module.ts | 27 +++++++++ 6 files changed, 184 insertions(+), 29 deletions(-) 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 b2123e8aeb18..79f565d9112d 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 @@ -350,6 +350,65 @@ describe('parseRoutes', () => { expect(parsedRoutes.children![0].canDeactivateGuards).toEqual(['CanDeactivateGuard']); }); + it('should handle matcher function', () => { + function customMatcher() { + return null; + } + + const nestedRouter = { + config: [ + { + matcher: customMatcher, + component: {name: 'MatcherComponent'}, + }, + ], + }; + + const parsedRoutes = parseRoutes(nestedRouter as any); + expect(parsedRoutes.children![0].matcher).toEqual('customMatcher()'); + expect(parsedRoutes.children![0].path).toEqual('[Matcher]'); + }); + + it('should handle runGuardsAndResolvers with string values', () => { + const nestedRouter = { + config: [ + { + path: 'always', + component: {name: 'Component'}, + runGuardsAndResolvers: 'always', + }, + { + path: 'params', + component: {name: 'Component2'}, + runGuardsAndResolvers: 'paramsOrQueryParamsChange', + }, + ], + }; + + const parsedRoutes = parseRoutes(nestedRouter as any); + expect(parsedRoutes.children![0].runGuardsAndResolvers).toEqual('always'); + expect(parsedRoutes.children![1].runGuardsAndResolvers).toEqual('paramsOrQueryParamsChange'); + }); + + it('should handle runGuardsAndResolvers with function', () => { + function customRerunLogic() { + return true; + } + + const nestedRouter = { + config: [ + { + path: 'custom', + component: {name: 'Component'}, + runGuardsAndResolvers: customRerunLogic, + }, + ], + }; + + const parsedRoutes = parseRoutes(nestedRouter as any); + expect(parsedRoutes.children![0].runGuardsAndResolvers).toEqual('customRerunLogic()'); + }); + it('should handle resolvers with named functions', () => { function userResolver() { return {id: 1, name: 'User'}; 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 3e2fb73236d5..7580dbad0f75 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/router-tree.ts @@ -15,7 +15,9 @@ export type RoutePropertyType = | 'component' | 'redirectTo' | 'title' - | 'resolvers'; + | 'resolvers' + | 'matcher' + | 'runGuardsAndResolvers'; export type RouteGuard = 'canActivate' | 'canActivateChild' | 'canDeactivate' | 'canMatch'; @@ -149,6 +151,17 @@ function assignChildrenToParent( routeConfig.redirectTo = getPropertyName(child, 'redirectTo'); } + if (child.matcher) { + routeConfig.matcher = getPropertyName(child, 'matcher'); + // For custom matchers, override the path to indicate it's a matcher + // Since the path be undefined when using a matcher, because the matcher defines the path matching + routeConfig.path = '[Matcher]'; + } + + if (child.runGuardsAndResolvers) { + routeConfig.runGuardsAndResolvers = getPropertyName(child, 'runGuardsAndResolvers'); + } + if (childDescendents) { routeConfig.children = assignChildrenToParent( routeConfig.path, @@ -211,7 +224,10 @@ function getClassOrFunctionName(fn: Function, defaultName?: string) { return isClass ? fn.name : `${fn.name}()`; } -function getPropertyName(child: AngularRoute, property: 'title' | 'redirectTo') { +function getPropertyName( + child: AngularRoute, + property: 'title' | 'redirectTo' | 'matcher' | 'runGuardsAndResolvers', +) { if (child[property] instanceof Function) { return getClassOrFunctionName(child[property], property); } @@ -242,14 +258,6 @@ export function getElementRefByName( name: string, ): any | null { for (const element of routes) { - if (type === 'title' && element.title instanceof Function) { - const functionName = getClassOrFunctionName(element.title); - //TODO: improve this, not every titleFn has a name property - if (functionName === name) { - return element.title; - } - } - if (type === 'resolvers' && element.resolve) { for (const key in element.resolve) { if (element.resolve.hasOwnProperty(key)) { @@ -262,11 +270,20 @@ export function getElementRefByName( } } - if (type === 'redirectTo' && element.redirectTo instanceof Function) { - const functionName = getClassOrFunctionName(element.redirectTo); - //TODO: improve this, not every redirectToFn has a name property - if (functionName === name) { - return element.redirectTo; + const functionProperties: Exclude[] = [ + 'title', + 'redirectTo', + 'matcher', + 'runGuardsAndResolvers', + ]; + + for (const property of functionProperties) { + if (type === property && element[property] instanceof Function) { + const functionName = getClassOrFunctionName(element[property]); + // TODO: improve this, not every function has a name property + if (functionName === name) { + return element[property]; + } } } diff --git a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html index cbcfcbded7a3..be7089a116d4 100644 --- a/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html +++ b/devtools/projects/ng-devtools/src/lib/devtools-tabs/router-tree/router-tree.component.html @@ -49,15 +49,27 @@

Routes Details

- + @if (!data.matcher) { + + } @else { + + } @if (!data.redirectTo) { Routes Details (actionBtnClick)="viewFunctionSource(data.title, 'title')" > } + + @if (data.runGuardsAndResolvers) { + + } + (); - readonly routerDebugApiSupport = input(false); + protected hasStaticOptionRunGuardsAndResolvers = computed(() => + RUN_GUARDS_AND_RESOLVERS_OPTIONS.includes( + this.routeData()?.runGuardsAndResolvers as RunGuardsAndResolvers, + ), + ); + + routes = input.required(); + routerDebugApiSupport = input(false); private readonly showFullPath = signal(false); protected readonly d3RootNode = linkedSignal(() => { @@ -150,7 +163,10 @@ export class RouterTreeComponent { ); } - viewFunctionSource(functionName: string, type: 'title' | 'redirectTo'): void { + viewFunctionSource( + functionName: string, + type: 'title' | 'redirectTo' | 'matcher' | 'runGuardsAndResolvers', + ): void { if (functionName === '[Function]') { const message = 'Cannot view the source of redirect functions defined inline (arrow or anonymous).'; diff --git a/devtools/projects/protocol/src/lib/messages.ts b/devtools/projects/protocol/src/lib/messages.ts index 6f78dddabb04..e8b2c8ce50af 100644 --- a/devtools/projects/protocol/src/lib/messages.ts +++ b/devtools/projects/protocol/src/lib/messages.ts @@ -330,7 +330,18 @@ export interface Route { isActive: boolean; isAux: boolean; isLazy: boolean; -} + matcher?: string; + runGuardsAndResolvers?: + | 'pathParamsChange' + | 'pathParamsOrQueryParamsChange' + | 'paramsChange' + | 'paramsOrQueryParamsChange' + | 'always' + | (string & {}); +} + +type OnlyLiterals = T extends string ? (string extends T ? never : T) : never; +export type RunGuardsAndResolvers = OnlyLiterals; export interface AngularDetection { // This is necessary because the runtime diff --git a/devtools/src/app/demo-app/todo/routes/routes.module.ts b/devtools/src/app/demo-app/todo/routes/routes.module.ts index 2d9876bce9a2..bd099155e776 100644 --- a/devtools/src/app/demo-app/todo/routes/routes.module.ts +++ b/devtools/src/app/demo-app/todo/routes/routes.module.ts @@ -15,6 +15,7 @@ import { ActivatedRouteSnapshot, Resolve, ResolveFn, + UrlSegment, } from '@angular/router'; import { @@ -42,6 +43,17 @@ export const activateGuard: CanActivateFn = ( return true; }; +export const customMatcher = (url: UrlSegment[]) => { + if (url.length === 1 && url[0].path === 'custom-matcher') { + return {consumed: url}; + } + return null; +}; + +export function customRerunLogic() { + return true; +} + @NgModule({ imports: [ CommonModule, @@ -68,6 +80,21 @@ export const activateGuard: CanActivateFn = ( providers: [Service1, Service2, Service3, Service4], loadComponent: () => import('./routes.component').then((x) => x.RoutesStandaloneComponent), }, + { + matcher: customMatcher, + component: RoutesHomeComponent, + }, + { + path: 'route-run-guards-and-resolvers', + component: RoutesHomeComponent, + canActivate: [activateGuard], + runGuardsAndResolvers: 'always', + }, + { + path: 'route-run-guards-and-resolvers-function', + component: RoutesHomeComponent, + runGuardsAndResolvers: customRerunLogic, + }, { path: 'route-data', component: RoutesHomeComponent,