From b625fba7c8c9ba74d2c9481d3bd6790f255f8187 Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 12:41:48 +0300 Subject: [PATCH 1/2] feat(demo): add a navigation ping-pong to the Routes lab The Routes lab had a guard loop for redirect loops but nothing that triggers a navigation loop, so testing that detection meant writing one by hand. A Navigation ping-pong button now navigates between Details and Summary from code six times in a row, which the Navigations view flags as a navigation loop. Fixes #102 --- .../src/content/contributing/demo-apps.md | 19 +++++------ apps/docs/src/content/inspectors/router.md | 2 +- src/app/examples/ping-pong.ts | 15 +++++++++ src/app/examples/route-guards.spec.ts | 32 +++++++++++++++++++ src/app/examples/routes-example.ts | 30 +++++++++++++---- 5 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 src/app/examples/ping-pong.ts diff --git a/apps/docs/src/content/contributing/demo-apps.md b/apps/docs/src/content/contributing/demo-apps.md index 0cfa420..f5d0989 100644 --- a/apps/docs/src/content/contributing/demo-apps.md +++ b/apps/docs/src/content/contributing/demo-apps.md @@ -42,15 +42,16 @@ Destination photos are from Unsplash, credited in `public/destinations/CREDITS.m `/examples/routes` has one link per router case, so the Router tab has something to show: -| Link | What it does | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| **Summary** | Target of the empty-path redirect. | -| **Details** | A plain child route. | -| **User 7** | A `:id` param with a slow resolver. | -| **Admin** | A guard that redirects to **Summary**. | -| **Locked** | A guard that returns `false`. | -| **Broken** | A resolver that throws. | -| **Guard loop** | `loop-a` and `loop-b` guards that redirect to each other five times, then to **Summary**. The Navigations view flags it as a redirect loop. | +| Link | What it does | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **Summary** | Target of the empty-path redirect. | +| **Details** | A plain child route. | +| **User 7** | A `:id` param with a slow resolver. | +| **Admin** | A guard that redirects to **Summary**. | +| **Locked** | A guard that returns `false`. | +| **Broken** | A resolver that throws. | +| **Guard loop** | `loop-a` and `loop-b` guards that redirect to each other five times, then to **Summary**. The Navigations view flags it as a redirect loop. | +| **Navigation ping-pong** | A button whose code navigates between **Details** and **Summary** six times in a row. The Navigations view flags it as a navigation loop. | Keep `redirectTo` cycles (`NG04016`) in the unit tests: Angular stops them before any guard runs. diff --git a/apps/docs/src/content/inspectors/router.md b/apps/docs/src/content/inspectors/router.md index 2d222da..7a10acb 100644 --- a/apps/docs/src/content/inspectors/router.md +++ b/apps/docs/src/content/inspectors/router.md @@ -46,7 +46,7 @@ The **Navigations** view looks for loops in the recorded navigations. A loop is | **navigation loop** | Your code calls `navigate` or `navigateByUrl` within 500 ms of the previous navigation ending, and the chain returns to an earlier URL. | | **redirect loop** (`NG04016`) | Angular stops a navigation with `NG04016` because `redirectTo` entries of the config form a cycle. | -To see one, click **Guard loop** in the Routes lab of the demo app (`/examples/routes`). Its two guards redirect to each other five times, then to **Summary**. +To see one, open the Routes lab of the demo app (`/examples/routes`). **Guard loop** makes two guards redirect to each other five times, then to **Summary** (a redirect loop). **Navigation ping-pong** navigates between **Details** and **Summary** from code (a navigation loop). When the view finds a loop, a **Loop detected** section appears above the list. It shows the cycle of URLs, such as `/account → /login → /account`. Under it, each hop names its cause: the guard, the `redirectTo` entry or the `navigate` call. A last line lists the navigation ids, how many times the chain came back, how it ended and the guards involved. diff --git a/src/app/examples/ping-pong.ts b/src/app/examples/ping-pong.ts new file mode 100644 index 0000000..2acc281 --- /dev/null +++ b/src/app/examples/ping-pong.ts @@ -0,0 +1,15 @@ +import type { Router } from '@angular/router'; + +export const PING_PONG_HOPS = 6; + +const DETAILS = '/examples/routes/details'; +const SUMMARY = '/examples/routes/summary'; + +/** Navigates back and forth between two lab routes from code, then stops. */ +export async function pingPong(router: Router, hops = PING_PONG_HOPS): Promise { + const first = router.url === DETAILS ? SUMMARY : DETAILS; + const second = first === DETAILS ? SUMMARY : DETAILS; + for (let hop = 0; hop < hops; hop++) { + await router.navigateByUrl(hop % 2 === 0 ? first : second); + } +} diff --git a/src/app/examples/route-guards.spec.ts b/src/app/examples/route-guards.spec.ts index af091b4..4c94bd4 100644 --- a/src/app/examples/route-guards.spec.ts +++ b/src/app/examples/route-guards.spec.ts @@ -1,6 +1,7 @@ import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationStart, Router, provideRouter } from '@angular/router'; +import { pingPong } from './ping-pong'; import { loopAGuard, loopBGuard } from './route-guards'; @Component({ template: '' }) @@ -15,6 +16,7 @@ describe('guard loop lab routes', () => { path: 'examples/routes', children: [ { path: 'summary', component: Page }, + { path: 'details', component: Page }, { path: 'loop-a', component: Page, canActivate: [loopAGuard] }, { path: 'loop-b', component: Page, canActivate: [loopBGuard] }, ], @@ -47,4 +49,34 @@ describe('guard loop lab routes', () => { ]); } }); + + it('navigates back and forth from code a few times, then stops', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/examples/routes/summary'); + const urls: string[] = []; + router.events.subscribe((event) => { + if (event instanceof NavigationStart) urls.push(event.url); + }); + + await pingPong(router, 4); + expect(urls).toEqual([ + '/examples/routes/details', + '/examples/routes/summary', + '/examples/routes/details', + '/examples/routes/summary', + ]); + expect(router.url).toBe('/examples/routes/summary'); + }); + + it('starts from the other page when it is already on Details', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/examples/routes/details'); + const urls: string[] = []; + router.events.subscribe((event) => { + if (event instanceof NavigationStart) urls.push(event.url); + }); + + await pingPong(router, 2); + expect(urls).toEqual(['/examples/routes/summary', '/examples/routes/details']); + }); }); diff --git a/src/app/examples/routes-example.ts b/src/app/examples/routes-example.ts index 6ef4c49..66fe0a2 100644 --- a/src/app/examples/routes-example.ts +++ b/src/app/examples/routes-example.ts @@ -1,6 +1,7 @@ -import { Component } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { Component, inject } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { ExamplePage } from './example-page'; +import { pingPong } from './ping-pong'; @Component({ selector: 'app-routes-example', @@ -25,6 +26,7 @@ import { ExamplePage } from './example-page'; Locked Broken Guard loop +
@@ -38,7 +40,8 @@ import { ExamplePage } from './example-page'; flex-wrap: wrap; gap: 4px; } - .sub a { + .sub a, + .sub button { padding: 6px 14px; border-radius: 6px; font-size: 14px; @@ -46,7 +49,15 @@ import { ExamplePage } from './example-page'; color: var(--muted); text-decoration: none; } - .sub a:hover { + .sub button { + border: 0; + background: none; + font: inherit; + font-size: 14px; + cursor: pointer; + } + .sub a:hover, + .sub button:hover { background: var(--subtle); color: var(--ink); } @@ -54,7 +65,8 @@ import { ExamplePage } from './example-page'; background: var(--brand-soft); color: var(--brand); } - .sub a:focus-visible { + .sub a:focus-visible, + .sub button:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; } @@ -66,4 +78,10 @@ import { ExamplePage } from './example-page'; } `, }) -export class RoutesExample {} +export class RoutesExample { + private readonly router = inject(Router); + + protected pingPong() { + void pingPong(this.router); + } +} From 7a8a3dd0d294bc4f373983d2354a839939f776ec Mon Sep 17 00:00:00 2001 From: Kam Date: Thu, 1 Oct 2026 13:37:49 +0300 Subject: [PATCH 2/2] fix(demo): run one navigation ping-pong at a time A second click on Navigation ping-pong started another chain that ran over the first. The button now ignores clicks while a chain runs and marks itself aria-disabled, staying focusable so keyboard focus is not lost. --- src/app/examples/routes-example.spec.ts | 46 +++++++++++++++++++++++++ src/app/examples/routes-example.ts | 23 ++++++++++--- 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 src/app/examples/routes-example.spec.ts diff --git a/src/app/examples/routes-example.spec.ts b/src/app/examples/routes-example.spec.ts new file mode 100644 index 0000000..9357847 --- /dev/null +++ b/src/app/examples/routes-example.spec.ts @@ -0,0 +1,46 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { RoutesExample } from './routes-example'; + +@Component({ template: '' }) +class Page {} + +describe('RoutesExample', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'examples/routes', + children: [ + { path: 'summary', component: Page }, + { path: 'details', component: Page }, + ], + }, + ]), + ], + }); + }); + + it('runs one ping-pong at a time and marks the button busy while it runs', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/examples/routes/summary'); + const fixture = TestBed.createComponent(RoutesExample); + await fixture.whenStable(); + const calls = vi.spyOn(router, 'navigateByUrl'); + const button = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find( + (el) => el.textContent?.includes('Navigation ping-pong'), + )!; + + button.click(); + fixture.detectChanges(); + expect(button.getAttribute('aria-disabled')).toBe('true'); + button.click(); + await fixture.whenStable(); + + expect(calls).toHaveBeenCalledTimes(6); + expect(button.hasAttribute('aria-disabled')).toBe(false); + }); +}); diff --git a/src/app/examples/routes-example.ts b/src/app/examples/routes-example.ts index 66fe0a2..f6ee2ee 100644 --- a/src/app/examples/routes-example.ts +++ b/src/app/examples/routes-example.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { ExamplePage } from './example-page'; import { pingPong } from './ping-pong'; @@ -26,7 +26,9 @@ import { pingPong } from './ping-pong'; Locked Broken Guard loop - +
@@ -49,6 +51,9 @@ import { pingPong } from './ping-pong'; color: var(--muted); text-decoration: none; } + .sub button[aria-disabled='true'] { + cursor: progress; + } .sub button { border: 0; background: none; @@ -57,7 +62,7 @@ import { pingPong } from './ping-pong'; cursor: pointer; } .sub a:hover, - .sub button:hover { + .sub button:not([aria-disabled='true']):hover { background: var(--subtle); color: var(--ink); } @@ -81,7 +86,15 @@ import { pingPong } from './ping-pong'; export class RoutesExample { private readonly router = inject(Router); - protected pingPong() { - void pingPong(this.router); + protected readonly running = signal(false); + + protected async pingPong() { + if (this.running()) return; + this.running.set(true); + try { + await pingPong(this.router); + } finally { + this.running.set(false); + } } }