Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions apps/docs/src/content/contributing/demo-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/inspectors/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions src/app/examples/ping-pong.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
32 changes: 32 additions & 0 deletions src/app/examples/route-guards.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '' })
Expand All @@ -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] },
],
Expand Down Expand Up @@ -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']);
});
});
46 changes: 46 additions & 0 deletions src/app/examples/routes-example.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
43 changes: 37 additions & 6 deletions src/app/examples/routes-example.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
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';

@Component({
selector: 'app-routes-example',
Expand All @@ -25,6 +26,9 @@ import { ExamplePage } from './example-page';
<a routerLink="locked" routerLinkActive="active" ariaCurrentWhenActive="page">Locked</a>
<a routerLink="broken" routerLinkActive="active" ariaCurrentWhenActive="page">Broken</a>
<a routerLink="loop-a" routerLinkActive="active" ariaCurrentWhenActive="page">Guard loop</a>
<button type="button" [attr.aria-disabled]="running() || null" (click)="pingPong()">
Navigation ping-pong
</button>
</nav>

<div class="outlet">
Expand All @@ -38,23 +42,36 @@ 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;
font-weight: 500;
color: var(--muted);
text-decoration: none;
}
.sub a:hover {
.sub button[aria-disabled='true'] {
cursor: progress;
}
.sub button {
border: 0;
background: none;
font: inherit;
font-size: 14px;
cursor: pointer;
}
.sub a:hover,
.sub button:not([aria-disabled='true']):hover {
background: var(--subtle);
color: var(--ink);
}
.sub a.active {
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;
}
Expand All @@ -66,4 +83,18 @@ import { ExamplePage } from './example-page';
}
`,
})
export class RoutesExample {}
export class RoutesExample {
private readonly router = inject(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);
}
}
}
Loading