(['a', 'b']);
+ cdr = injectHere(ChangeDetectorRef);
}
Component({
+ ...STANDALONE,
selector: 'app-list-baseline',
imports: [JoinPipe],
template: `{{ items() | join }}
`,
@@ -698,9 +730,10 @@ describe('pipes collector', () => {
it('flags a Date mutated in place and shown with the real DatePipe', async () => {
class When {
when = new Date(2020, 0, 1);
- cdr = inject(ChangeDetectorRef);
+ cdr = injectHere(ChangeDetectorRef);
}
Component({
+ ...STANDALONE,
selector: 'app-when',
imports: [DatePipe],
template: `{{ when | date: 'yyyy' }}
`,
@@ -730,9 +763,10 @@ describe('pipes collector', () => {
])('flags a %s mutated in place', async (_kind, create, mutate) => {
class Bag {
bag = create();
- cdr = inject(ChangeDetectorRef);
+ cdr = injectHere(ChangeDetectorRef);
}
Component({
+ ...STANDALONE,
selector: `app-bag-${_kind.toLowerCase()}`,
imports: [KeysPipe],
template: `{{ bag | keys }}
`,
@@ -750,10 +784,11 @@ describe('pipes collector', () => {
it('does not flag a pipe that reran because another argument changed', async () => {
class Search {
items = ['apple', 'banana'];
- term = signal('a');
- cdr = inject(ChangeDetectorRef);
+ term = writableSignal('a');
+ cdr = injectHere(ChangeDetectorRef);
}
Component({
+ ...STANDALONE,
selector: 'app-search',
imports: [FilterPipe],
template: `{{ items | filter: term() }}
`,
@@ -773,10 +808,11 @@ describe('pipes collector', () => {
it('clears a stale finding once the pipe reruns', async () => {
class Search {
items = ['apple', 'banana'];
- term = signal('a');
- cdr = inject(ChangeDetectorRef);
+ term = writableSignal('a');
+ cdr = injectHere(ChangeDetectorRef);
}
Component({
+ ...STANDALONE,
selector: 'app-search2',
imports: [FilterPipe],
template: `{{ items | filter: term() }}
`,
@@ -800,7 +836,7 @@ describe('pipes collector', () => {
const cached = of('cached');
class Feed {
stable$ = new BehaviorSubject('stable');
- cdr = inject(ChangeDetectorRef);
+ cdr = injectHere(ChangeDetectorRef);
getData(): Observable {
return of('fresh');
}
@@ -809,6 +845,7 @@ describe('pipes collector', () => {
}
}
Component({
+ ...STANDALONE,
selector: 'app-resubscribe',
imports: [AsyncPipe],
template: `{{ getData() | async }}
{{ getCached() | async }}
{{ stable$ | async }}
`,
@@ -837,9 +874,10 @@ describe('pipes collector', () => {
it('does not mark an async pipe whose source changed once', async () => {
class Feed {
source$ = new BehaviorSubject('one');
- cdr = inject(ChangeDetectorRef);
+ cdr = injectHere(ChangeDetectorRef);
}
Component({
+ ...STANDALONE,
selector: 'app-switch',
imports: [AsyncPipe],
template: `{{ source$ | async }}
`,
diff --git a/packages/devtools/src/__tests__/pipes-runtime.test.ts b/packages/devtools/src/__tests__/pipes-runtime.test.ts
index 6d6f97ef..ceb5c49c 100644
--- a/packages/devtools/src/__tests__/pipes-runtime.test.ts
+++ b/packages/devtools/src/__tests__/pipes-runtime.test.ts
@@ -1,9 +1,8 @@
// @vitest-environment jsdom
import '@angular/compiler';
-import { Component, Pipe, TemplateRef, signal } from '@angular/core';
+import { Component, Pipe, TemplateRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { CurrencyPipe, NgTemplateOutlet, UpperCasePipe } from '@angular/common';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
+import { CurrencyPipe, NgForOf, NgIf, NgTemplateOutlet, UpperCasePipe } from '@angular/common';
import { afterEach, describe, expect, it } from 'vitest';
import {
findPipeUsages,
@@ -14,12 +13,15 @@ import {
staleCheckFor,
type PipeCall,
} from '../pipes-runtime.ts';
+import {
+ controlFlow,
+ initAngularTestBed,
+ testModuleFor,
+ writableSignal,
+ STANDALONE,
+} from './angular-version.ts';
-try {
- TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
-} catch {
- // already initialized in this worker
-}
+initAngularTestBed();
afterEach(() => {
TestBed.resetTestingModule();
@@ -27,6 +29,8 @@ afterEach(() => {
});
async function mount(type: new () => T) {
+ const declared = testModuleFor(type);
+ if (declared.declarations) TestBed.configureTestingModule(declared);
const fixture = TestBed.createComponent(type);
document.body.appendChild(fixture.nativeElement);
fixture.detectChanges();
@@ -43,6 +47,7 @@ class Price {
name = 'widget';
}
Component({
+ ...STANDALONE,
selector: 'app-price',
imports: [CurrencyPipe, UpperCasePipe],
template: `{{ amount | currency }}
{{ name | uppercase }}
`,
@@ -100,9 +105,10 @@ describe('pipe runtime discovery (real Angular render)', () => {
});
class Cart {
- items = signal(['a', 'b']);
+ items = writableSignal(['a', 'b']);
}
Component({
+ ...STANDALONE,
selector: 'app-cart',
imports: [UpperCasePipe],
// json isn't imported here; slice the array to a single-string binding so
@@ -115,6 +121,7 @@ class Labelled {
value = 'a';
}
Component({
+ ...STANDALONE,
selector: 'app-labelled',
imports: [UpperCasePipe],
template: `uppercase | {{ value | uppercase }}
`,
@@ -162,17 +169,21 @@ describe('stale pure-pipe check (experimental, real Angular render)', () => {
});
class Badge {}
-Component({ selector: 'app-badge', template: `badge` })(Badge);
+Component({ ...STANDALONE, selector: 'app-badge', template: `badge` })(Badge);
class Board {
- show = signal(true);
- items = signal(['x', 'y']);
+ show = writableSignal(true);
+ items = writableSignal(['x', 'y']);
title = 'board';
}
Component({
+ ...STANDALONE,
selector: 'app-board',
- imports: [Badge, UpperCasePipe],
- template: `@if (show()) {{{ title | uppercase }}}@for (item of items(); track item) {{{ item | uppercase }}}`,
+ imports: [Badge, UpperCasePipe, ...(controlFlow ? [] : [NgIf, NgForOf])],
+ // Views that hold only text: control flow blocks from 17.0, containers before.
+ template: controlFlow
+ ? `@if (show()) {{{ title | uppercase }}}@for (item of items(); track item) {{{ item | uppercase }}}`
+ : `{{ title | uppercase }}{{ item | uppercase }}`,
})(Board);
class _ShoutPipe {
@@ -180,13 +191,14 @@ class _ShoutPipe {
return `${value}!`;
}
}
-Pipe({ name: 'shout' })(_ShoutPipe);
+Pipe({ ...STANDALONE, name: 'shout' })(_ShoutPipe);
class _Shell {
- a = signal('one');
- b = signal('two');
+ a = writableSignal('one');
+ b = writableSignal('two');
}
Component({
+ ...STANDALONE,
selector: 'app-shell',
imports: [_ShoutPipe],
template: `{{ a() | shout }}
{{ b() | shout }}
`,
@@ -196,19 +208,21 @@ class _Frame {
tpl: TemplateRef | null = null;
}
Component({
+ ...STANDALONE,
selector: 'app-frame',
inputs: ['tpl'],
imports: [NgTemplateOutlet],
- template: ``,
+ template: ``,
})(_Frame);
class _Declarer {
label = 'row';
}
Component({
+ ...STANDALONE,
selector: 'app-declarer',
imports: [_Frame, UpperCasePipe],
- template: `{{ label | uppercase }}`,
+ template: `{{ label | uppercase }}`,
})(_Declarer);
describe('pipe discovery through the view tree', () => {
diff --git a/packages/devtools/src/__tests__/private-api-canary.test.ts b/packages/devtools/src/__tests__/private-api-canary.test.ts
index b2e11161..db9fad04 100644
--- a/packages/devtools/src/__tests__/private-api-canary.test.ts
+++ b/packages/devtools/src/__tests__/private-api-canary.test.ts
@@ -1,17 +1,18 @@
// @vitest-environment jsdom
import '@angular/compiler';
-import {
- Component,
- Directive,
- provideZonelessChangeDetection,
- signal,
- ɵsetClassDebugInfo,
- type ApplicationRef,
-} from '@angular/core';
-import { bootstrapApplication } from '@angular/platform-browser';
-import { provideRouter } from '@angular/router';
+import { APP_BASE_HREF } from '@angular/common';
+import * as core from '@angular/core';
+import { Component, Directive } from '@angular/core';
+import { RouterModule } from '@angular/router';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-import { angularAtLeast } from './angular-version.ts';
+import {
+ angularAtLeast,
+ bootstrapRoot,
+ writableSignal,
+ STANDALONE,
+ type BootedApp,
+} from './angular-version.ts';
+import { routerApi } from './router-testing.ts';
/**
* Every private (`ɵ`) and public debug symbol the collectors read from the
@@ -20,7 +21,7 @@ import { angularAtLeast } from './angular-version.ts';
* the collectors fall back or report that the page has no data.
*/
-/** `window.ng` utils every supported version (Angular 20.0+) publishes. */
+/** `window.ng` utils every supported version (Angular 12.0+) publishes. */
const REQUIRED_NG_UTILS = [
// component-tree, forms, pipes-runtime, signal-graph, cd-overlay, router
'getComponent',
@@ -32,45 +33,54 @@ const REQUIRED_NG_UTILS = [
'getDirectiveMetadata',
'getRootComponents',
'applyChanges',
- 'isSignal',
- // injector-tree, router, router-setup, ngrx-collector, forms-webmcp
- 'ɵgetInjectorProviders',
- 'ɵgetInjectorResolutionPath',
- 'ɵgetInjectorMetadata',
- 'ɵgetDependenciesFromInjectable',
// change-detection, cd-overlay
'ɵsetProfiler',
- // signal-graph
- 'ɵgetSignalGraph',
] as const;
/**
* Utils that exist only from a given version, or only up to one. Each entry
- * names the version that brought it in (`since`) or took it out (`until`).
+ * names the version that brought it in (`since`) or took it out (`until`), as
+ * docs/angular-compat.md lists them.
*/
const VERSIONED_NG_UTILS = [
+ // injector-tree, router, router-setup, ngrx-collector, forms-webmcp
+ { name: 'ɵgetInjectorProviders', since: '16.2.0' },
+ { name: 'ɵgetInjectorResolutionPath', since: '16.2.0' },
+ { name: 'ɵgetDependenciesFromInjectable', since: '16.2.0' },
+ { name: 'ɵgetInjectorMetadata', since: '17.0.0' },
+ // component-tree, component-values, signal-graph
+ { name: 'isSignal', since: '17.1.0' },
+ // signal-graph
+ { name: 'ɵgetSignalGraph', since: '19.1.0' },
+ // cd-live
+ { name: 'enableProfiling', since: '20.0.0' },
// defer-blocks: renamed from `ɵgetDeferBlocks` in Angular 22.0.
{ name: 'ɵgetControlFlowBlocks', since: '22.0.0' },
- { name: 'ɵgetDeferBlocks', until: '22.0.0' },
+ { name: 'ɵgetDeferBlocks', since: '19.1.0', until: '22.0.0' },
// router: provideRouter publishes it since Angular 20.3.5; before that the
// overlay finds the Router through `ɵgetInjectorProviders`.
{ name: 'ɵgetRouterInstance', since: '20.3.5' },
] as const;
class TripBadge {}
-Directive({ selector: '[appTripBadge]' })(TripBadge);
+Directive({ ...STANDALONE, selector: '[appTripBadge]' })(TripBadge);
class TripCard {
tripId = '';
- readonly selected = signal(false);
+ readonly selected = writableSignal(false);
}
Component({
+ ...STANDALONE,
selector: 'app-trip-card',
imports: [TripBadge],
- inputs: [{ name: 'tripId', alias: 'trip' }],
+ // The string form; the object form of inputs exists from 16.0.
+ inputs: ['tripId: trip'],
template: 'Trip',
})(TripCard);
-ɵsetClassDebugInfo(TripCard, {
+// Dev builds record where a component is declared from 17.0.
+const setClassDebugInfo = (core as unknown as Record)['ɵsetClassDebugInfo'] as
+ ((type: unknown, info: unknown) => void) | undefined;
+setClassDebugInfo?.(TripCard, {
className: 'TripCard',
filePath: 'src/app/trip-card.ts',
lineNumber: 12,
@@ -78,24 +88,33 @@ Component({
class TripPage {}
Component({
+ ...STANDALONE,
selector: 'app-root',
imports: [TripCard],
- template: '',
+ template: '',
})(TripPage);
type Ng = Record;
type Def = Record;
describe('private API canary', () => {
- let app: ApplicationRef;
+ let app: BootedApp;
let ng: Ng;
beforeAll(async () => {
document.body.innerHTML = '';
- app = await bootstrapApplication(TripPage, {
- providers: [provideZonelessChangeDetection(), provideRouter([])],
+ // provideRouter publishes ɵgetRouterInstance; before 14.2 there is only forRoot.
+ const provideRouter =
+ routerApi<(routes: unknown[]) => core.EnvironmentProviders>('provideRouter');
+ app = await bootstrapRoot(TripPage, {
+ declarations: [TripCard, TripBadge],
+ providers: [
+ { provide: APP_BASE_HREF, useValue: '/' },
+ ...(provideRouter ? [provideRouter([])] : []),
+ ],
+ modules: provideRouter ? [] : [RouterModule.forRoot([])],
});
- app.tick();
+ app.appRef.tick();
ng = (globalThis as { ng?: Ng }).ng!;
});
@@ -115,7 +134,8 @@ describe('private API canary', () => {
expect(typeof ng[entry.name] === 'function', `ng.${entry.name}`).toBe(expected);
});
- it('ɵsetProfiler hands back a remover (Angular 20.0+)', () => {
+ // Before 20.0 setting a profiler evicts the one in place, so the overlay never calls it.
+ it.runIf(angularAtLeast('20.0'))('ɵsetProfiler hands back a remover (Angular 20.0+)', () => {
const remove = (ng['ɵsetProfiler'] as (fn: () => void) => unknown)(() => {});
expect(typeof remove).toBe('function');
(remove as () => void)();
@@ -128,12 +148,16 @@ describe('private API canary', () => {
expect((cmp!['selectors'] as unknown[][])[0][0]).toBe('app-trip-card');
// router-config, router-links: inputs keyed by public name
expect(Object.keys(cmp!['inputs'] as object)).toContain('trip');
- // component-source, router: debugInfo set by ɵsetClassDebugInfo in dev builds
- expect(cmp!['debugInfo']).toMatchObject({
- className: 'TripCard',
- filePath: 'src/app/trip-card.ts',
- lineNumber: 12,
- });
+ // component-source, router: debugInfo set by ɵsetClassDebugInfo in dev builds (17.0+)
+ if (setClassDebugInfo) {
+ expect(cmp!['debugInfo']).toMatchObject({
+ className: 'TripCard',
+ filePath: 'src/app/trip-card.ts',
+ lineNumber: 12,
+ });
+ } else {
+ expect(cmp!['debugInfo']).toBeUndefined();
+ }
// forms: ɵdir.selectors
const dir = (TripBadge as unknown as { ɵdir?: Def }).ɵdir;
expect((dir!['selectors'] as unknown[][])[0]).toContain('appTripBadge');
@@ -149,7 +173,20 @@ describe('private API canary', () => {
const span = host.querySelector('span')!;
expect((ng['getDirectives'] as (el: Element) => unknown[])(span)[0]).toBeInstanceOf(TripBadge);
expect((ng['getOwningComponent'] as (el: Element) => unknown)(span)).toBe(card);
- expect((ng['isSignal'] as (v: unknown) => boolean)((card as TripCard).selected)).toBe(true);
+ if (angularAtLeast('17.1')) {
+ expect((ng['isSignal'] as (v: unknown) => boolean)((card as TripCard).selected)).toBe(true);
+ }
+
+ // pipes-runtime: 12.x and 13.x patch the LView array onto the node; from 14.0
+ // it is a view id that getComponent upgrades to an LContext with an lView.
+ const context = (host as unknown as { __ngContext__?: unknown }).__ngContext__;
+ if (angularAtLeast('14.0')) {
+ expect(Array.isArray((context as { lView?: unknown })?.lView)).toBe(true);
+ } else {
+ const lView = Array.isArray(context) ? context : (context as { lView?: unknown })?.lView;
+ expect(Array.isArray(lView)).toBe(true);
+ }
+ if (!angularAtLeast('17.0')) return;
const injector = (ng['getInjector'] as (el: Element) => unknown)(host);
const meta = (ng['ɵgetInjectorMetadata'] as (i: unknown) => { type: string } | null)(injector);
@@ -165,9 +202,5 @@ describe('private API canary', () => {
);
expect(providers.length).toBeGreaterThan(0);
expect(providers[0]).toHaveProperty('token');
-
- // pipes-runtime: after getComponent, __ngContext__ is an LContext with an lView array.
- const context = (host as unknown as { __ngContext__?: { lView?: unknown } }).__ngContext__;
- expect(Array.isArray(context?.lView)).toBe(true);
});
});
diff --git a/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts b/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts
index 68c407c5..ebddf0f3 100644
--- a/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts
+++ b/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts
@@ -2,13 +2,13 @@
import '@angular/compiler';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { Router, provideRouter, type Routes } from '@angular/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { watchRouter, type NavigationRecord } from '../router.ts';
import { runAction } from '../router-actions.ts';
+import { initAngularTestBed } from './angular-version.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
class Page {}
Component({ selector: 'app-page', template: '' })(Page);
diff --git a/packages/devtools/src/__tests__/router-actions-wait.test.ts b/packages/devtools/src/__tests__/router-actions-wait.test.ts
index 0110dc6c..b5381452 100644
--- a/packages/devtools/src/__tests__/router-actions-wait.test.ts
+++ b/packages/devtools/src/__tests__/router-actions-wait.test.ts
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import '@angular/compiler';
-import { Component, PendingTasks, inject } from '@angular/core';
+import * as core from '@angular/core';
+import { Component, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import {
RedirectCommand,
Router,
@@ -13,12 +13,13 @@ import {
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { watchRouter, type NavigationRecord } from '../router.ts';
import { runAction, waitForStable } from '../router-actions.ts';
-import { angularAtLeast } from './angular-version.ts';
+import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts';
+import { functionalRouter } from './router-testing.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
class Page {}
-Component({ selector: 'app-page', template: '' })(Page);
+Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page);
let landingGuardRuns = 0;
let landingResolverRuns = 0;
@@ -61,7 +62,26 @@ const routes: Routes = [
},
];
-describe('router actions that wait on the app', () => {
+/** The Router holds the root pending tasks from 16.0. */
+const routerTasks = angularAtLeast('16.0');
+
+/**
+ * Adds a pending task and returns its remover: through the public
+ * `PendingTasks` from 19.0, through the root tasks the Router holds before.
+ */
+function addPendingTask(router: Router): () => void {
+ const publicTasks = (core as unknown as Record)['PendingTasks'];
+ if (publicTasks && angularAtLeast('19.0')) {
+ return TestBed.inject(publicTasks as core.Type<{ add(): () => void }>).add();
+ }
+ const tasks = (router as unknown as { pendingTasks: { add(): number; remove(id: number): void } })
+ .pendingTasks;
+ const id = tasks.add();
+ return () => tasks.remove(id);
+}
+
+// provideRouter and functional guards exist from 14.2.
+describe.runIf(functionalRouter)('router actions that wait on the app', () => {
let router: Router;
let navigations: NavigationRecord[];
let stop: (() => void) | null;
@@ -90,16 +110,19 @@ describe('router actions that wait on the app', () => {
afterEach(() => stop?.());
- it('waitFor "navigation" returns when the navigation ends, without waiting for pending tasks', async () => {
- const done = TestBed.inject(PendingTasks).add();
- const result = await act({ action: 'navigate', url: '/plain', waitFor: 'navigation' });
- expect(result).toMatchObject({ outcome: 'succeeded', url: '/plain' });
- expect(result['stable']).toBeUndefined();
- done();
- });
+ it.runIf(routerTasks)(
+ 'waitFor "navigation" returns when the navigation ends, without waiting for pending tasks',
+ async () => {
+ const done = addPendingTask(router);
+ const result = await act({ action: 'navigate', url: '/plain', waitFor: 'navigation' });
+ expect(result).toMatchObject({ outcome: 'succeeded', url: '/plain' });
+ expect(result['stable']).toBeUndefined();
+ done();
+ },
+ );
- it('waitFor "stable" waits until the app has no pending tasks', async () => {
- const done = TestBed.inject(PendingTasks).add();
+ it.runIf(routerTasks)('waitFor "stable" waits until the app has no pending tasks', async () => {
+ const done = addPendingTask(router);
setTimeout(done, 400);
const started = Date.now();
const result = await act({ action: 'navigate', url: '/plain', waitFor: 'stable' });
@@ -107,8 +130,8 @@ describe('router actions that wait on the app', () => {
expect(result).toMatchObject({ outcome: 'succeeded', stable: true });
});
- it('says when the app did not become stable in time', async () => {
- const done = TestBed.inject(PendingTasks).add();
+ it.runIf(routerTasks)('says when the app did not become stable in time', async () => {
+ const done = addPendingTask(router);
expect(await waitForStable(router as never, 30)).toBe(false);
done();
expect(await waitForStable(router as never, 30)).toBe(true);
@@ -118,6 +141,15 @@ describe('router actions that wait on the app', () => {
expect(await waitForStable({} as never, 30)).toBeNull();
});
+ it.skipIf(routerTasks)(
+ 'reports an unknown stability before 16.0, whose Router has no pending tasks',
+ async () => {
+ expect(await waitForStable(router as never, 30)).toBeNull();
+ const result = await act({ action: 'navigate', url: '/plain', waitFor: 'stable' });
+ expect(result).toMatchObject({ outcome: 'succeeded', url: '/plain' });
+ },
+ );
+
it.skipIf(!angularAtLeast('20.2.0')).each([
['a UrlTree', '/members'],
['a RedirectCommand', '/vip'],
diff --git a/packages/devtools/src/__tests__/router-audit.test.ts b/packages/devtools/src/__tests__/router-audit.test.ts
index 3f45b3ad..49602578 100644
--- a/packages/devtools/src/__tests__/router-audit.test.ts
+++ b/packages/devtools/src/__tests__/router-audit.test.ts
@@ -2,7 +2,6 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { Router, RouterLink, RouterLinkActive, provideRouter, type Routes } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -24,9 +23,9 @@ import {
storedInstrumented,
} from '../router-actions.ts';
import { touchRouterPage } from '../rpc/router-tools.ts';
-import { angularAtLeast } from './angular-version.ts';
+import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
class Nav {
readonly router = inject(Router);
@@ -36,6 +35,7 @@ class Nav {
}
}
Component({
+ ...STANDALONE,
selector: 'app-nav',
imports: [RouterLink, RouterLinkActive],
template: `
@@ -59,8 +59,13 @@ Component({
- @if (nullOptions) {
+ ${
+ // Control flow blocks exist from 17.0; the link only matters on 22.0+.
+ angularAtLeast('17.0')
+ ? `@if (nullOptions) {
Never
+ }`
+ : ''
}
`,
})(Nav);
@@ -86,7 +91,8 @@ const routes: Routes = [
},
];
-describe('router audit fixes on a real Router', () => {
+// RouterTestingHarness exists from 15.2.
+describe.runIf(angularAtLeast('15.2'))('router audit fixes on a real Router', () => {
let router: Router;
let navigations: NavigationRecord[];
let cleanup: (() => void)[];
@@ -237,18 +243,22 @@ describe('route-config secrets before any navigation event', () => {
expect(adopted?.url).toBe('/reset/[redacted]');
});
- it('masks a short path-param token in a rendered routerLink href', async () => {
- TestBed.resetTestingModule();
- TestBed.configureTestingModule({
- providers: [provideRouter([{ path: 'invite/:code', component: Nav }, ...routes])],
- });
- const real = TestBed.inject(Router);
- const harness = await RouterTestingHarness.create();
- await harness.navigateByUrl('/a');
- harness.detectChanges();
- const links = linksOf((globalThis as { ng?: RouterDebugApi }).ng!, real as never);
- expect(links.find((l) => l.text === 'Invite')?.href).toBe('/invite/[redacted]');
- });
+ // RouterTestingHarness exists from 15.2.
+ it.runIf(angularAtLeast('15.2'))(
+ 'masks a short path-param token in a rendered routerLink href',
+ async () => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [provideRouter([{ path: 'invite/:code', component: Nav }, ...routes])],
+ });
+ const real = TestBed.inject(Router);
+ const harness = await RouterTestingHarness.create();
+ await harness.navigateByUrl('/a');
+ harness.detectChanges();
+ const links = linksOf((globalThis as { ng?: RouterDebugApi }).ng!, real as never);
+ expect(links.find((l) => l.text === 'Invite')?.href).toBe('/invite/[redacted]');
+ },
+ );
});
describe('router heartbeat', () => {
diff --git a/packages/devtools/src/__tests__/router-features.test.ts b/packages/devtools/src/__tests__/router-features.test.ts
index 23949361..0f05a9f7 100644
--- a/packages/devtools/src/__tests__/router-features.test.ts
+++ b/packages/devtools/src/__tests__/router-features.test.ts
@@ -2,7 +2,6 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import {
PreloadAllModules,
RedirectCommand,
@@ -30,18 +29,22 @@ import {
type PreloadRecord,
} from '../router-actions.ts';
import { matchUrl } from '../rpc/router-config-tools.ts';
-import { angularAtLeast } from './angular-version.ts';
+import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts';
+import { functionalRouter, routerTestModule } from './router-testing.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+/** The navigation error handler can redirect (`RedirectCommand`) from 18.0. */
+const handlerRedirects = angularAtLeast('18.0');
+
+initAngularTestBed();
class Page {}
-Component({ selector: 'app-page', template: '' })(Page);
+Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page);
class UserPage {
id?: string;
user?: unknown;
}
-Component({ selector: 'app-user', template: '', inputs: ['id', 'user'] })(UserPage);
+Component({ ...STANDALONE, selector: 'app-user', template: '', inputs: ['id', 'user'] })(UserPage);
const authGuard = () => inject(Router).parseUrl('/login');
const slowResolver = () => new Promise((resolve) => setTimeout(() => resolve('late'), 40));
@@ -81,7 +84,8 @@ const routes: Routes = [
{ path: '**', component: Page },
];
-describe('router features on a real Router', () => {
+// provideRouter and functional guards exist from 14.2.
+describe.runIf(functionalRouter)('router features on a real Router', () => {
let router: Router;
let navigations: NavigationRecord[];
let cleanup: (() => void)[];
@@ -157,7 +161,9 @@ describe('router features on a real Router', () => {
total: expect.any(Number),
recognize: expect.any(Number),
});
- expect(last().checked?.activate).toEqual(['/users/:id']);
+ // The guard checks are read from the router's current transition, exposed from 16.2.
+ if (angularAtLeast('16.2')) expect(last().checked?.activate).toEqual(['/users/:id']);
+ else expect(last().checked).toBeUndefined();
});
it('links a guard redirect to the navigation it starts', async () => {
@@ -172,7 +178,7 @@ describe('router features on a real Router', () => {
expect(login).toMatchObject({ url: '/login', redirectedFrom: admin.id });
});
- it('keeps what the navigation error handler did', async () => {
+ it.runIf(handlerRedirects)('keeps what the navigation error handler did', async () => {
await router.navigateByUrl('/broken').catch(() => {});
const broken = navigations.find((n) => n.url === '/broken')!;
expect(broken.errorHandler).toMatch(/redirected to \/login/);
@@ -187,16 +193,19 @@ describe('router features on a real Router', () => {
expect(last()).toMatchObject({ url: '/login', redirectedFrom: broken.id });
});
- it('keeps the error code of an error the handler redirected away from', async () => {
- await router.navigateByUrl('/coded').catch(() => {});
- const coded = navigations.find((n) => n.url === '/coded')!;
- expect(coded).toMatchObject({
- outcome: 'redirected',
- redirectKind: 'error handler',
- reason: 'Error: NG04002: db down',
- errorCode: 'NG04002',
- });
- });
+ it.runIf(handlerRedirects)(
+ 'keeps the error code of an error the handler redirected away from',
+ async () => {
+ await router.navigateByUrl('/coded').catch(() => {});
+ const coded = navigations.find((n) => n.url === '/coded')!;
+ expect(coded).toMatchObject({
+ outcome: 'redirected',
+ redirectKind: 'error handler',
+ reason: 'Error: NG04002: db down',
+ errorCode: 'NG04002',
+ });
+ },
+ );
it('records calls that throw before a navigation starts', () => {
expect(() =>
@@ -326,29 +335,35 @@ describe('router features on a real Router', () => {
});
});
- it('maps outlets to routed components and their router-bound inputs', async () => {
- TestBed.resetTestingModule();
- TestBed.configureTestingModule({
- providers: [
- provideRouter(routes, (await import('@angular/router')).withComponentInputBinding()),
- ],
- });
- const harness = await RouterTestingHarness.create();
- await harness.navigateByUrl('/users/9');
- const outlets = outletsOf(TestBed.inject(Router) as never);
- expect(outlets[0]).toMatchObject({ outlet: 'primary', activated: true });
- const leaf = outlets[0].children?.[0] ?? outlets[0];
- expect(leaf).toMatchObject({ component: 'UserPage', route: '/users/:id' });
- expect(leaf.inputs).toEqual([
- { input: 'id', source: 'param' },
- { input: 'user', source: 'data' },
- ]);
- });
+ // withComponentInputBinding exists from 16.0.
+ it.runIf(angularAtLeast('16.0'))(
+ 'maps outlets to routed components and their router-bound inputs',
+ async () => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ provideRouter(routes, (await import('@angular/router')).withComponentInputBinding()),
+ ],
+ });
+ const harness = await RouterTestingHarness.create();
+ await harness.navigateByUrl('/users/9');
+ const outlets = outletsOf(TestBed.inject(Router) as never);
+ expect(outlets[0]).toMatchObject({ outlet: 'primary', activated: true });
+ const leaf = outlets[0].children?.[0] ?? outlets[0];
+ expect(leaf).toMatchObject({ component: 'UserPage', route: '/users/:id' });
+ expect(leaf.inputs).toEqual([
+ { input: 'id', source: 'param' },
+ { input: 'user', source: 'data' },
+ ]);
+ },
+ );
});
-describe('routerOutletData', () => {
+// routerOutletData exists from 19.0.
+describe.runIf(angularAtLeast('19.0'))('routerOutletData', () => {
class Shell {}
Component({
+ ...STANDALONE,
selector: 'app-shell',
imports: [RouterOutlet],
template: `
@@ -403,7 +418,7 @@ describe('routerOutletData', () => {
});
});
-describe('preloads', () => {
+describe.runIf(functionalRouter)('preloads', () => {
it('records preloads that run between navigations', async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
@@ -452,7 +467,7 @@ describe('helpers', () => {
it('predicts matches like the default matcher', () => {
TestBed.resetTestingModule();
- TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
+ TestBed.configureTestingModule(routerTestModule(routes));
const tree = walkConfig(TestBed.inject(Router) as never);
expect(matchUrl(tree, '/users/3').chain.map((n) => n.fullPath)).toEqual([
'/users',
diff --git a/packages/devtools/src/__tests__/router-forroot.test.ts b/packages/devtools/src/__tests__/router-forroot.test.ts
index e0161854..54822610 100644
--- a/packages/devtools/src/__tests__/router-forroot.test.ts
+++ b/packages/devtools/src/__tests__/router-forroot.test.ts
@@ -1,26 +1,26 @@
// @vitest-environment jsdom
import '@angular/compiler';
-import { Component, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core';
-import { bootstrapApplication } from '@angular/platform-browser';
+import { APP_BASE_HREF } from '@angular/common';
+import { Component } from '@angular/core';
import { PreloadAllModules, Router, RouterModule, type ExtraOptions } from '@angular/router';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { routerOf } from '../analog-runtime.ts';
import { findRouter, type RouterDebugApi } from '../router.ts';
import { detectSetup, publishesRouterUtil } from '../router-setup.ts';
+import { angularAtLeast, bootstrapRoot, STANDALONE, type BootedApp } from './angular-version.ts';
+import { injectorProviders } from './router-testing.ts';
class Root {}
-Component({ selector: 'app-root', template: '' })(Root);
+Component({ ...STANDALONE, selector: 'app-root', template: '' })(Root);
-let app: Awaited>;
+let app: BootedApp;
let ng: RouterDebugApi;
async function boot(options?: ExtraOptions) {
document.body.innerHTML = '';
- app = await bootstrapApplication(Root, {
- providers: [
- provideZonelessChangeDetection(),
- importProvidersFrom(RouterModule.forRoot([{ path: '', component: Root }], options)),
- ],
+ app = await bootstrapRoot(Root, {
+ providers: [{ provide: APP_BASE_HREF, useValue: '/' }],
+ modules: [RouterModule.forRoot([{ path: '', component: Root }], options)],
});
const published = (globalThis as { ng?: RouterDebugApi }).ng!;
ng = {
@@ -34,13 +34,37 @@ beforeEach(() => boot());
afterEach(() => app.destroy());
+// Before 16.2 the Router is only found when a component or router link holds
+// it (router-fallback.ts); this root holds nothing, so there is no router data.
+const found = () => (injectorProviders ? app.injector.get(Router) : null);
+
it('finds the router of a RouterModule.forRoot app through the injector chain', () => {
expect((globalThis as { ng?: RouterDebugApi }).ng!.ɵgetRouterInstance).toBeUndefined();
- expect(findRouter(ng, [document.querySelector('app-root')!])).toBe(app.injector.get(Router));
+ expect(findRouter(ng, [document.querySelector('app-root')!])).toBe(found());
});
it('finds the router for the Analog report without ng.ɵgetRouterInstance', () => {
- expect(routerOf(ng as never)).toBe(app.injector.get(Router));
+ expect(routerOf(ng as never)).toBe(found());
+});
+
+it('finds the router a root component holds on every version', async () => {
+ app.destroy();
+ class Holder {
+ constructor(readonly router: Router) {}
+ }
+ Component({ ...STANDALONE, selector: 'app-root', template: '' })(Holder);
+ (Holder as unknown as { ctorParameters: () => unknown[] }).ctorParameters = () => [
+ { type: Router },
+ ];
+ document.body.innerHTML = '';
+ app = await bootstrapRoot(Holder, {
+ providers: [{ provide: APP_BASE_HREF, useValue: '/' }],
+ modules: [RouterModule.forRoot([])],
+ });
+ const published = (globalThis as { ng?: RouterDebugApi }).ng!;
+ expect(findRouter(published, [document.querySelector('app-root')!])).toBe(
+ app.injector.get(Router),
+ );
});
it('reports forRoot from its guard token on every version, with or without the router util', () => {
@@ -49,10 +73,13 @@ it('reports forRoot from its guard token on every version, with or without the r
root.setAttribute('ng-version', version);
return detectSetup(ng, app.injector.get(Router) as never, 1, root).setupKind;
};
+ // The guard token is a provider, read through ɵgetInjectorProviders (16.2+).
+ // Without it, a version that publishes the router util tells forRoot apart
+ // by the util's absence; an older one can't tell.
expect(setupOn('22.1.7')).toBe('forRoot or other');
expect(setupOn('20.3.5')).toBe('forRoot or other');
- expect(setupOn('20.3.4')).toBe('forRoot or other');
- expect(setupOn('20.0.0')).toBe('forRoot or other');
+ expect(setupOn('20.3.4')).toBe(injectorProviders ? 'forRoot or other' : 'unknown');
+ expect(setupOn('20.0.0')).toBe(injectorProviders ? 'forRoot or other' : 'unknown');
});
it('knows which versions publish the router util', () => {
@@ -83,19 +110,34 @@ it('reads the RouterModule.forRoot options on a real Router', async () => {
const setup = detectSetup(ng, app.injector.get(Router) as never, 1, root);
vi.restoreAllMocks();
const values = Object.fromEntries(setup.options.map((o) => [o.name, [o.value, o.set]]));
- expect(values).toMatchObject({
- initialNavigation: ['enabledBlocking', true],
- scrollPositionRestoration: ['top', true],
- anchorScrolling: ['enabled', true],
- onSameUrlNavigation: ['reload', true],
- paramsInheritanceStrategy: ['always', true],
- });
+ if (angularAtLeast('15.0')) {
+ expect(values).toMatchObject({
+ initialNavigation: ['enabledBlocking', true],
+ scrollPositionRestoration: ['top', true],
+ anchorScrolling: ['enabled', true],
+ onSameUrlNavigation: ['reload', true],
+ paramsInheritanceStrategy: ['always', true],
+ });
+ } else {
+ // Router.options holds the forRoot options from 15.0. Before, only the
+ // options the Router keeps as fields are read, and none shows as set.
+ expect(values).toMatchObject({
+ initialNavigation: ['enabledNonBlocking', false],
+ scrollPositionRestoration: ['disabled', false],
+ anchorScrolling: ['disabled', false],
+ onSameUrlNavigation: ['reload', false],
+ paramsInheritanceStrategy: ['always', false],
+ });
+ }
+ // Each option exists from the version named; preloading and the scroller are
+ // found through their providers (16.2).
+ const on = (since: string, value = 'on') => (angularAtLeast(since) ? value : 'off');
expect(setup.features).toMatchObject({
- componentInputBinding: 'on',
- viewTransitions: 'on',
- preloading: 'PreloadAllModules',
- scroller: 'on',
- debugTracing: 'on',
+ componentInputBinding: on('16.0'),
+ viewTransitions: on('17.0'),
+ preloading: on('16.2', 'PreloadAllModules'),
+ scroller: on('16.2'),
+ debugTracing: on('15.0'),
platformNavigation: 'off',
});
expect(setup.strategies['locationStrategy']).toBe('HashLocationStrategy');
diff --git a/packages/devtools/src/__tests__/router-guard-results.test.ts b/packages/devtools/src/__tests__/router-guard-results.test.ts
index 6480d76b..c4659454 100644
--- a/packages/devtools/src/__tests__/router-guard-results.test.ts
+++ b/packages/devtools/src/__tests__/router-guard-results.test.ts
@@ -2,7 +2,6 @@
import '@angular/compiler';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { Router, provideRouter, type Routes } from '@angular/router';
import {
BehaviorSubject,
@@ -16,8 +15,10 @@ import {
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { watchRouter, type NavigationRecord } from '../router.ts';
import { instrument } from '../router-actions.ts';
+import { initAngularTestBed, STANDALONE } from './angular-version.ts';
+import { functionalRouter } from './router-testing.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
class StateObservable extends Observable {
constructor(state: Observable) {
@@ -39,7 +40,7 @@ class FakeStore extends Observable {
}
class Page {}
-Component({ selector: 'app-page', template: 'page' })(Page);
+Component({ ...STANDALONE, selector: 'app-page', template: 'page' })(Page);
const state = new BehaviorSubject({ authed: true });
const behavior = new BehaviorSubject(true);
@@ -80,63 +81,67 @@ function wrapGuard(value: Observable) {
return { wrapped, nav };
}
-describe('instrumented guards keep the observable they return working', () => {
- let router: Router;
- let navigations: NavigationRecord[];
- let cleanup: (() => void)[];
- const last = () => navigations[navigations.length - 1];
-
- beforeEach(() => {
- TestBed.resetTestingModule();
- TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
- router = TestBed.inject(Router);
- navigations = [];
- cleanup = [];
- const stop = watchRouter(router as never, navigations, () => {});
- if (stop) cleanup.push(stop);
- cleanup.push(instrument(router as never, navigations));
- subject = new Subject();
- asyncValue = later(true);
- });
-
- afterEach(() => {
- for (const fn of cleanup) fn();
- });
-
- it('passes a Store-like observable with its own lift and constructor', async () => {
- expect(storeGuard()).toBeInstanceOf(FakeStore);
- await expect(router.navigateByUrl('/store')).resolves.toBe(true);
- expect(router.url).toBe('/store');
- expect(last().runs).toEqual([
- expect.objectContaining({ guard: 'storeGuard', kind: 'canActivate', result: 'true' }),
- ]);
- });
-
- it('passes the current value of a BehaviorSubject, not a function', async () => {
- behavior.next(false);
- await expect(router.navigateByUrl('/behavior')).resolves.toBe(false);
- expect(last().runs?.[0]).toMatchObject({ guard: 'behaviorGuard', result: 'false' });
- behavior.next(true);
- await expect(router.navigateByUrl('/behavior')).resolves.toBe(true);
- expect(last().runs?.[0]).toMatchObject({ result: 'true' });
- });
-
- it('waits for a Subject to emit', async () => {
- const navigation = router.navigateByUrl('/subject');
- await new Promise((resolve) => setTimeout(resolve, 5));
- subject.next(true);
- await expect(navigation).resolves.toBe(true);
- expect(last().runs?.[0]).toMatchObject({ guard: 'subjectGuard', result: 'true' });
- });
-
- it('records an async guard and resolver on their first value', async () => {
- await expect(router.navigateByUrl('/async')).resolves.toBe(true);
- expect(last().runs?.map((run) => [run.kind, run.guard, run.result])).toEqual([
- ['canActivate', 'asyncGuard', 'true'],
- ['resolve', 'v: asyncResolver', 'string'],
- ]);
- });
-});
+// provideRouter and functional guards exist from 14.2.
+describe.runIf(functionalRouter)(
+ 'instrumented guards keep the observable they return working',
+ () => {
+ let router: Router;
+ let navigations: NavigationRecord[];
+ let cleanup: (() => void)[];
+ const last = () => navigations[navigations.length - 1];
+
+ beforeEach(() => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
+ router = TestBed.inject(Router);
+ navigations = [];
+ cleanup = [];
+ const stop = watchRouter(router as never, navigations, () => {});
+ if (stop) cleanup.push(stop);
+ cleanup.push(instrument(router as never, navigations));
+ subject = new Subject();
+ asyncValue = later(true);
+ });
+
+ afterEach(() => {
+ for (const fn of cleanup) fn();
+ });
+
+ it('passes a Store-like observable with its own lift and constructor', async () => {
+ expect(storeGuard()).toBeInstanceOf(FakeStore);
+ await expect(router.navigateByUrl('/store')).resolves.toBe(true);
+ expect(router.url).toBe('/store');
+ expect(last().runs).toEqual([
+ expect.objectContaining({ guard: 'storeGuard', kind: 'canActivate', result: 'true' }),
+ ]);
+ });
+
+ it('passes the current value of a BehaviorSubject, not a function', async () => {
+ behavior.next(false);
+ await expect(router.navigateByUrl('/behavior')).resolves.toBe(false);
+ expect(last().runs?.[0]).toMatchObject({ guard: 'behaviorGuard', result: 'false' });
+ behavior.next(true);
+ await expect(router.navigateByUrl('/behavior')).resolves.toBe(true);
+ expect(last().runs?.[0]).toMatchObject({ result: 'true' });
+ });
+
+ it('waits for a Subject to emit', async () => {
+ const navigation = router.navigateByUrl('/subject');
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ subject.next(true);
+ await expect(navigation).resolves.toBe(true);
+ expect(last().runs?.[0]).toMatchObject({ guard: 'subjectGuard', result: 'true' });
+ });
+
+ it('records an async guard and resolver on their first value', async () => {
+ await expect(router.navigateByUrl('/async')).resolves.toBe(true);
+ expect(last().runs?.map((run) => [run.kind, run.guard, run.result])).toEqual([
+ ['canActivate', 'asyncGuard', 'true'],
+ ['resolve', 'v: asyncResolver', 'string'],
+ ]);
+ });
+ },
+);
describe('tracked guard observables', () => {
it('records the first value of an async source read through first()', async () => {
diff --git a/packages/devtools/src/__tests__/router-loops.test.ts b/packages/devtools/src/__tests__/router-loops.test.ts
index 9ec1c243..3a57ad8f 100644
--- a/packages/devtools/src/__tests__/router-loops.test.ts
+++ b/packages/devtools/src/__tests__/router-loops.test.ts
@@ -2,7 +2,6 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import {
RedirectCommand,
Router,
@@ -17,8 +16,10 @@ import type { RouteNode } from '../router-config.ts';
import { lintRoutes } from '../rpc/router-config-tools.ts';
import { detectLoops, redirectCycles } from '../rpc/router-loops.ts';
import { describeNavigation, type RouterPage } from '../rpc/router-tools.ts';
+import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts';
+import { functionalRouter } from './router-testing.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
function nav(id: number, extra: Partial = {}): NavigationRecord {
return {
@@ -208,7 +209,7 @@ describe('detectLoops', () => {
});
class Page {}
-Component({ selector: 'app-page', template: '' })(Page);
+Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page);
let signedIn = false;
let bounces = 0;
@@ -231,7 +232,8 @@ const routes: Routes = [
{ path: 'step/4', component: Page },
];
-describe('detectLoops on a real Router', () => {
+// provideRouter and functional guards exist from 14.2.
+describe.runIf(functionalRouter)('detectLoops on a real Router', () => {
let router: Router;
let navigations: NavigationRecord[];
let cleanup: (() => void)[];
@@ -275,11 +277,18 @@ describe('detectLoops on a real Router', () => {
it('flags a redirectTo loop that Angular stops with NG04016', async () => {
await router.navigateByUrl('/x').catch(() => {});
const failed = navigations[navigations.length - 1];
- expect(failed).toMatchObject({ url: '/x', outcome: 'failed', errorCode: 'NG04016' });
const config: RouteNode[] = [
{ id: '3', path: 'x', fullPath: '/x', kind: 'redirect', redirectTo: '/y' },
{ id: '4', path: 'y', fullPath: '/y', kind: 'redirect', redirectTo: '/x' },
];
+ if (!angularAtLeast('17.0')) {
+ // Angular names the loop (NG04016) from 17.0. Before, the navigation fails
+ // with "Cannot match any routes" (NG04002) and is not reported as a loop.
+ expect(failed).toMatchObject({ url: '/x', outcome: 'failed', errorCode: 'NG04002' });
+ expect(detectLoops(navigations, config)).toEqual([]);
+ return;
+ }
+ expect(failed).toMatchObject({ url: '/x', outcome: 'failed', errorCode: 'NG04016' });
expect(detectLoops(navigations, config)).toEqual([
expect.objectContaining({ kind: 'config', ids: [failed.id], cycle: ['/x', '/y', '/x'] }),
]);
@@ -300,7 +309,8 @@ function flakyResolver() {
}
const backToReport = () => inject(Router).parseUrl('/report');
-describe('detectLoops through the navigation error handler', () => {
+// The navigation error handler can redirect (`RedirectCommand`) from 18.0.
+describe.runIf(angularAtLeast('18.0'))('detectLoops through the navigation error handler', () => {
let router: Router;
let navigations: NavigationRecord[];
let cleanup: (() => void)[];
diff --git a/packages/devtools/src/__tests__/router-real.test.ts b/packages/devtools/src/__tests__/router-real.test.ts
index c1d19395..ed4c4d77 100644
--- a/packages/devtools/src/__tests__/router-real.test.ts
+++ b/packages/devtools/src/__tests__/router-real.test.ts
@@ -2,7 +2,6 @@
import '@angular/compiler';
import { Component, Injector, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import {
PreloadAllModules,
Router,
@@ -20,12 +19,13 @@ import {
type NavigationRecord,
type RouterDebugApi,
} from '../router.ts';
-import { angularAtLeast } from './angular-version.ts';
+import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts';
+import { functionalRouter } from './router-testing.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
class Page {}
-Component({ selector: 'app-page', template: '' })(Page);
+Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page);
const authGuard = () => inject(Router).parseUrl('/login');
const denyGuard = () => false;
@@ -75,7 +75,8 @@ const routes: Routes = [
{ path: 'lazy', loadChildren: () => Promise.resolve([{ path: '', component: Page }]) },
];
-describe('real Router', () => {
+// The routes use functional guards and resolvers, which exist from 14.2 with provideRouter.
+describe.runIf(functionalRouter)('real Router', () => {
let router: Router;
let navigations: NavigationRecord[];
let stop: (() => void) | null;
diff --git a/packages/devtools/src/__tests__/router-regressions.test.ts b/packages/devtools/src/__tests__/router-regressions.test.ts
index aa78bebd..777b2ae3 100644
--- a/packages/devtools/src/__tests__/router-regressions.test.ts
+++ b/packages/devtools/src/__tests__/router-regressions.test.ts
@@ -112,6 +112,32 @@ describe('router events', () => {
expect(navigations.map((nav) => nav.trigger)).toEqual(['popstate', 'unknown']);
});
+ it('links a guard redirect from a cancel with no code, as Angular 12 to 14 send it', () => {
+ const navigations: NavigationRecord[] = [];
+ const router = { url: '/', routerState: { snapshot: null } };
+ applyRouterEvent(
+ navigations,
+ { type: EventType.NavigationStart, id: 1, url: '/admin' },
+ 0,
+ router,
+ );
+ applyRouterEvent(
+ navigations,
+ { type: EventType.NavigationCancel, id: 1, url: '/admin', reason: 'Redirecting to "/login"' },
+ 1,
+ router,
+ );
+ applyRouterEvent(
+ navigations,
+ { type: EventType.NavigationStart, id: 2, url: '/login' },
+ 2,
+ router,
+ );
+ const [admin, login] = navigations;
+ expect(admin).toMatchObject({ outcome: 'redirected', code: 'Redirect', redirectTo: '/login' });
+ expect(login).toMatchObject({ url: '/login', redirectedFrom: admin.id });
+ });
+
it('reports the title set after NavigationEnd', async () => {
const navigations: NavigationRecord[] = [];
const router = { url: '/', routerState: { snapshot: null } };
diff --git a/packages/devtools/src/__tests__/router-setup-legacy.test.ts b/packages/devtools/src/__tests__/router-setup-legacy.test.ts
index c5301101..728785dc 100644
--- a/packages/devtools/src/__tests__/router-setup-legacy.test.ts
+++ b/packages/devtools/src/__tests__/router-setup-legacy.test.ts
@@ -1,60 +1,72 @@
// @vitest-environment jsdom
import '@angular/compiler';
-import { Component, provideZonelessChangeDetection } from '@angular/core';
+import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { Router, provideRouter } from '@angular/router';
-import { afterEach, beforeEach, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { routerOf } from '../analog-runtime.ts';
import { findRouter, type RouterDebugApi } from '../router.ts';
import { detectSetup } from '../router-setup.ts';
+import { zonelessProviders, STANDALONE } from './angular-version.ts';
+import { functionalRouter, injectorProviders } from './router-testing.ts';
class Root {}
-Component({ selector: 'app-root', template: '' })(Root);
+Component({ ...STANDALONE, selector: 'app-root', template: '' })(Root);
-let app: Awaited>;
-let ng: RouterDebugApi;
-let util: unknown;
+// provideRouter exists from 14.2.
+describe.runIf(functionalRouter)('a provideRouter app without the router util', () => {
+ let app: Awaited>;
+ let ng: RouterDebugApi;
+ let util: unknown;
-beforeEach(async () => {
- document.body.innerHTML = '';
- app = await bootstrapApplication(Root, {
- providers: [provideZonelessChangeDetection(), provideRouter([{ path: '', component: Root }])],
+ beforeEach(async () => {
+ document.body.innerHTML = '';
+ app = await bootstrapApplication(Root, {
+ providers: [...zonelessProviders(), provideRouter([{ path: '', component: Root }])],
+ });
+ const published = (globalThis as { ng?: RouterDebugApi & Record }).ng!;
+ util = published['ɵgetRouterInstance'];
+ delete published['ɵgetRouterInstance'];
+ ng = {
+ getInjector: published.getInjector,
+ ɵgetInjectorResolutionPath: published.ɵgetInjectorResolutionPath,
+ ɵgetInjectorProviders: published.ɵgetInjectorProviders,
+ };
});
- const published = (globalThis as { ng?: RouterDebugApi & Record }).ng!;
- util = published['ɵgetRouterInstance'];
- delete published['ɵgetRouterInstance'];
- ng = {
- getInjector: published.getInjector,
- ɵgetInjectorResolutionPath: published.ɵgetInjectorResolutionPath,
- ɵgetInjectorProviders: published.ɵgetInjectorProviders,
- };
-});
-afterEach(() => {
- (globalThis as { ng?: Record }).ng!['ɵgetRouterInstance'] = util;
- app.destroy();
-});
+ afterEach(() => {
+ (globalThis as { ng?: Record }).ng!['ɵgetRouterInstance'] = util;
+ app.destroy();
+ });
-it('finds the router of a provideRouter app without ng.ɵgetRouterInstance', () => {
- const roots = [document.querySelector('app-root')!];
- expect(findRouter(ng, roots)).toBe(app.injector.get(Router));
- expect(routerOf(ng as never)).toBe(app.injector.get(Router));
-});
+ it('finds the router of a provideRouter app without ng.ɵgetRouterInstance', () => {
+ const roots = [document.querySelector('app-root')!];
+ // Before 16.2 only a component or link holding the Router finds it; this root holds none.
+ const expected = injectorProviders ? app.injector.get(Router) : null;
+ expect(findRouter(ng, roots)).toBe(expected);
+ expect(routerOf(ng as never)).toBe(expected);
+ });
-it.each(['20.3.4', '20.3.5'])(
- 'reports provideRouter from the root ROUTES when the router util is missing on %s',
- (version) => {
- const root = document.querySelector('app-root')!;
- root.setAttribute('ng-version', version);
- expect(detectSetup(ng, app.injector.get(Router) as never, 1, root).setupKind).toBe(
- 'provideRouter',
- );
- },
-);
+ it.each(['20.3.4', '20.3.5'])(
+ 'reports provideRouter from the root ROUTES when the router util is missing on %s',
+ (version) => {
+ const root = document.querySelector('app-root')!;
+ root.setAttribute('ng-version', version);
+ // ROUTES is a provider (16.2+). Before, 20.3.5 and later tell the setup
+ // apart by the router util, and an older version can't.
+ const expected = injectorProviders
+ ? 'provideRouter'
+ : version === '20.3.5'
+ ? 'forRoot or other'
+ : 'unknown';
+ expect(detectSetup(ng, app.injector.get(Router) as never, 1, root).setupKind).toBe(expected);
+ },
+ );
-it('reports unknown when neither the util nor the providers tell the setup apart', () => {
- const root = document.querySelector('app-root')!;
- root.setAttribute('ng-version', '20.3.4');
- const bare: RouterDebugApi = { ...ng, ɵgetInjectorProviders: () => [] };
- expect(detectSetup(bare, app.injector.get(Router) as never, 1, root).setupKind).toBe('unknown');
+ it('reports unknown when neither the util nor the providers tell the setup apart', () => {
+ const root = document.querySelector('app-root')!;
+ root.setAttribute('ng-version', '20.3.4');
+ const bare: RouterDebugApi = { ...ng, ɵgetInjectorProviders: () => [] };
+ expect(detectSetup(bare, app.injector.get(Router) as never, 1, root).setupKind).toBe('unknown');
+ });
});
diff --git a/packages/devtools/src/__tests__/router-setup-real.test.ts b/packages/devtools/src/__tests__/router-setup-real.test.ts
index b9eb234e..55de0da3 100644
--- a/packages/devtools/src/__tests__/router-setup-real.test.ts
+++ b/packages/devtools/src/__tests__/router-setup-real.test.ts
@@ -1,12 +1,7 @@
// @vitest-environment jsdom
import '@angular/compiler';
import { HashLocationStrategy, PlatformNavigation } from '@angular/common';
-import {
- Component,
- provideZonelessChangeDetection,
- type EnvironmentProviders,
- type Provider,
-} from '@angular/core';
+import { Component, type EnvironmentProviders, type Provider } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import {
PreloadAllModules,
@@ -28,6 +23,8 @@ import * as router from '@angular/router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RouterDebugApi } from '../router.ts';
import { detectSetup, type RouterSetup } from '../router-setup.ts';
+import { angularAtLeast, zonelessProviders, STANDALONE } from './angular-version.ts';
+import { functionalRouter, injectorProviders } from './router-testing.ts';
const optionalRouter: Partial = router;
const withExperimentalAutoCleanupInjectors = optionalRouter.withExperimentalAutoCleanupInjectors;
@@ -35,7 +32,7 @@ const withExperimentalPlatformNavigation = optionalRouter.withExperimentalPlatfo
const withRouterResources = optionalRouter.ɵwithRouterResources;
class Root {}
-Component({ selector: 'app-root', template: '' })(Root);
+Component({ ...STANDALONE, selector: 'app-root', template: '' })(Root);
const routes = [{ path: '', component: Root }];
let destroy: (() => void) | null = null;
@@ -51,7 +48,7 @@ async function setupWith(
): Promise {
document.body.innerHTML = '';
const app = await bootstrapApplication(Root, {
- providers: [provideZonelessChangeDetection(), provideRouter(routes, ...features), ...extra],
+ providers: [...zonelessProviders(), provideRouter(routes, ...features), ...extra],
});
destroy = () => app.destroy();
const ng = (globalThis as { ng?: RouterDebugApi }).ng!;
@@ -61,11 +58,29 @@ async function setupWith(
const option = (setup: RouterSetup, name: string) => setup.options.find((o) => o.name === name);
-describe('detectSetup on a real provideRouter app', () => {
+/**
+ * What the overlay reads for each feature, and the first version it can: from
+ * the Router's own fields once the feature stores one there, else from the
+ * providers (`ɵgetInjectorProviders`, 16.2). Before that the feature reads `off`.
+ */
+const READABLE_FROM: Record = {
+ componentInputBinding: '16.0',
+ viewTransitions: '17.0',
+ // Before 18.0 the handler is an environment initializer the Router does not hold.
+ navigationErrorHandler: '18.0',
+ routerResources: '20.0',
+ injectorCleanup: '20.0',
+ preloading: '16.2',
+ scroller: '16.2',
+};
+
+// `provideRouter` and its features exist from 14.2.
+describe.runIf(functionalRouter)('detectSetup on a real provideRouter app', () => {
it('reports defaults when no feature is used', async () => {
const setup = await setupWith([]);
- expect(setup.setupKind).toBe('provideRouter');
- expect(setup.mode).toBe('full');
+ // Before 16.2 nothing tells provideRouter apart, and only router events are read.
+ expect(setup.setupKind).toBe(injectorProviders ? 'provideRouter' : 'unknown');
+ expect(setup.mode).toBe(injectorProviders ? 'full' : 'events-only');
expect(option(setup, 'initialNavigation')).toEqual({
name: 'initialNavigation',
value: 'enabledNonBlocking',
@@ -87,15 +102,21 @@ describe('detectSetup on a real provideRouter app', () => {
it('reads the initial navigation mode of withEnabledBlockingInitialNavigation()', async () => {
const setup = await setupWith([withEnabledBlockingInitialNavigation()]);
- expect(option(setup, 'initialNavigation')).toMatchObject({
- value: 'enabledBlocking',
- set: true,
- });
+ // The mode is read from a provider, so before 16.2 the default shows.
+ expect(option(setup, 'initialNavigation')).toMatchObject(
+ injectorProviders
+ ? { value: 'enabledBlocking', set: true }
+ : { value: 'enabledNonBlocking', set: false },
+ );
});
it('reads the initial navigation mode of withDisabledInitialNavigation()', async () => {
const setup = await setupWith([withDisabledInitialNavigation()]);
- expect(option(setup, 'initialNavigation')).toMatchObject({ value: 'disabled', set: true });
+ expect(option(setup, 'initialNavigation')).toMatchObject(
+ injectorProviders
+ ? { value: 'disabled', set: true }
+ : { value: 'enabledNonBlocking', set: false },
+ );
});
it('detects withDebugTracing()', async () => {
@@ -103,7 +124,8 @@ describe('detectSetup on a real provideRouter app', () => {
vi.spyOn(console, 'group').mockImplementation(() => {});
vi.spyOn(console, 'groupEnd').mockImplementation(() => {});
const setup = await setupWith([withDebugTracing()]);
- expect(setup.features['debugTracing']).toBe('on');
+ // withDebugTracing() is found in the injector records on the resolution path (16.2).
+ expect(setup.features['debugTracing']).toBe(injectorProviders ? 'on' : 'off');
vi.restoreAllMocks();
});
@@ -121,7 +143,7 @@ describe('detectSetup on a real provideRouter app', () => {
).filter(([, feature]) => typeof feature === 'function'),
)('detects the %s feature', async (name, feature, value) => {
const setup = await setupWith([(feature as () => unknown)() as RouterFeatures]);
- expect(setup.features[name]).toBe(value);
+ expect(setup.features[name]).toBe(angularAtLeast(READABLE_FROM[name]!) ? value : 'off');
});
it.skipIf(typeof withExperimentalPlatformNavigation !== 'function')(
@@ -160,6 +182,9 @@ describe('detectSetup on a real provideRouter app', () => {
}),
]);
const values = Object.fromEntries(setup.options.map((o) => [o.name, [o.value, o.set]]));
+ // The scroll options come from the RouterScroller, found through its provider (16.2).
+ const scroll = (value: string, fallback: string) =>
+ injectorProviders ? [value, false] : [fallback, false];
expect(values).toMatchObject({
onSameUrlNavigation: ['reload', true],
paramsInheritanceStrategy: ['always', true],
@@ -167,8 +192,8 @@ describe('detectSetup on a real provideRouter app', () => {
canceledNavigationResolution: ['computed', true],
defaultQueryParamsHandling: ['merge', true],
resolveNavigationPromiseOnError: ['true', true],
- scrollPositionRestoration: ['top', false],
- anchorScrolling: ['enabled', false],
+ scrollPositionRestoration: scroll('top', 'disabled'),
+ anchorScrolling: scroll('enabled', 'disabled'),
});
});
diff --git a/packages/devtools/src/__tests__/router-shared-guards.test.ts b/packages/devtools/src/__tests__/router-shared-guards.test.ts
index ca48f61d..8e932128 100644
--- a/packages/devtools/src/__tests__/router-shared-guards.test.ts
+++ b/packages/devtools/src/__tests__/router-shared-guards.test.ts
@@ -2,16 +2,17 @@
import '@angular/compiler';
import { Component, Injectable } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
-import { Router, provideRouter, type Routes } from '@angular/router';
+import { Router, type Routes } from '@angular/router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { watchRouter, type NavigationRecord } from '../router.ts';
import { instrument } from '../router-actions.ts';
+import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts';
+import { routerTestModule } from './router-testing.ts';
-TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
+initAngularTestBed();
class Page {}
-Component({ selector: 'app-page', template: '' })(Page);
+Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page);
class AuthGuard {
canActivate() {
@@ -59,7 +60,7 @@ describe('instrumented class guards and resolvers shared by several routes', ()
beforeEach(async () => {
TestBed.resetTestingModule();
- TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
+ TestBed.configureTestingModule(routerTestModule(routes));
router = TestBed.inject(Router);
navigations = [];
cleanup = [];
@@ -86,12 +87,83 @@ describe('instrumented class guards and resolvers shared by several routes', ()
]);
});
- it('names the route for canMatch and canActivateChild runs', async () => {
- await router.navigateByUrl('/m2');
- expect(last().runs).toEqual([expect.objectContaining({ kind: 'canMatch', route: '/m2' })]);
- await router.navigateByUrl('/p2/c');
- expect(last().runs).toEqual([
- expect.objectContaining({ kind: 'canActivateChild', route: '/p2' }),
- ]);
+ // canMatch exists from 14.1.
+ it.runIf(angularAtLeast('14.1'))(
+ 'names the route for canMatch and canActivateChild runs',
+ async () => {
+ await router.navigateByUrl('/m2');
+ // Before 16.0 Angular matches routes twice (redirects, then recognition), running canMatch in each pass.
+ const run = expect.objectContaining({ kind: 'canMatch', route: '/m2' });
+ expect(last().runs).toEqual(angularAtLeast('16.0') ? [run] : [run, run]);
+ await router.navigateByUrl('/p2/c');
+ expect(last().runs).toEqual([
+ expect.objectContaining({ kind: 'canActivateChild', route: '/p2' }),
+ ]);
+ },
+ );
+});
+
+class DenyGuard {
+ canActivate() {
+ return false;
+ }
+}
+Injectable({ providedIn: 'root' })(DenyGuard);
+
+class FailingResolver {
+ resolve(): never {
+ throw new Error('user service is down');
+ }
+}
+Injectable({ providedIn: 'root' })(FailingResolver);
+
+// Class guards work on every version, so this is the router coverage for 12.0 to
+// 14.1, which have neither functional guards nor provideRouter.
+describe('navigation records from class guards and resolvers', () => {
+ let router: Router;
+ let navigations: NavigationRecord[];
+ let stop: (() => void) | null;
+ const last = () => navigations[navigations.length - 1]!;
+
+ beforeEach(async () => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule(
+ routerTestModule([
+ { path: '', component: Page },
+ { path: 'a', component: Page, canActivate: [AuthGuard], resolve: { user: UserResolver } },
+ { path: 'blocked', component: Page, canActivate: [DenyGuard] },
+ { path: 'broken', component: Page, resolve: { user: FailingResolver } },
+ { path: 'old', redirectTo: 'a' },
+ ]),
+ );
+ router = TestBed.inject(Router);
+ navigations = [];
+ stop = watchRouter(router as never, navigations, () => {});
+ await router.navigateByUrl('/');
+ });
+
+ afterEach(() => stop?.());
+
+ it('records a navigation with its guards and resolvers', async () => {
+ await router.navigateByUrl('/old');
+ expect(last()).toMatchObject({
+ url: '/old',
+ finalUrl: '/a',
+ outcome: 'succeeded',
+ guards: { names: ['AuthGuard'], passed: true },
+ resolvers: { names: ['user: UserResolver'] },
+ });
+ });
+
+ it('records a guard that blocks and a resolver that fails', async () => {
+ await router.navigateByUrl('/blocked');
+ expect(last()).toMatchObject({
+ url: '/blocked',
+ outcome: 'cancelled',
+ guards: { names: ['DenyGuard'], passed: false },
+ });
+ await router.navigateByUrl('/broken').catch(() => {});
+ expect(last()).toMatchObject({ url: '/broken', outcome: 'failed' });
+ expect(last().reason).toContain('user service is down');
});
});
diff --git a/packages/devtools/src/__tests__/router-testing.ts b/packages/devtools/src/__tests__/router-testing.ts
new file mode 100644
index 00000000..0cb1bd07
--- /dev/null
+++ b/packages/devtools/src/__tests__/router-testing.ts
@@ -0,0 +1,43 @@
+import { APP_BASE_HREF } from '@angular/common';
+import type { TestModuleMetadata } from '@angular/core/testing';
+import * as router from '@angular/router';
+import type { Routes } from '@angular/router';
+import { angularAtLeast } from './angular-version.ts';
+
+type Feature = (...args: never[]) => unknown;
+
+const api = router as unknown as Record;
+
+/**
+ * A router function by name, or undefined on a version without it, for tests
+ * that run on every version the compat workflow installs.
+ */
+export function routerApi(name: string): T | undefined {
+ const value = api[name];
+ return typeof value === 'function' ? (value as T) : undefined;
+}
+
+/**
+ * The TestBed setup for `routes`: `provideRouter` with `features` from 14.2,
+ * `RouterModule.forRoot` before (where `features` must be empty).
+ */
+export function routerTestModule(routes: Routes, ...features: unknown[]): TestModuleMetadata {
+ const provideRouter =
+ routerApi<(routes: Routes, ...features: unknown[]) => unknown>('provideRouter');
+ if (provideRouter) return { providers: [provideRouter(routes, ...features) as never] };
+ if (features.length) throw new Error('Router features need provideRouter (Angular 14.2+)');
+ return {
+ imports: [router.RouterModule.forRoot(routes)],
+ providers: [{ provide: APP_BASE_HREF, useValue: '/' }],
+ };
+}
+
+/** Functional guards and resolvers, and `provideRouter`, exist from 14.2. */
+export const functionalRouter = angularAtLeast('14.2');
+
+/**
+ * The overlay reads providers and environment injectors through
+ * `ɵgetInjectorProviders`, which Angular publishes from 16.2. Before it the
+ * router setup is reported in `events-only` mode.
+ */
+export const injectorProviders = angularAtLeast('16.2');
diff --git a/packages/devtools/src/__tests__/signal-graph-real.test.ts b/packages/devtools/src/__tests__/signal-graph-real.test.ts
index d4c6abab..7a28c195 100644
--- a/packages/devtools/src/__tests__/signal-graph-real.test.ts
+++ b/packages/devtools/src/__tests__/signal-graph-real.test.ts
@@ -18,7 +18,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
import { bootstrapApplication } from '@angular/platform-browser';
import { afterEach, describe, expect, it } from 'vitest';
import { collectSignalGraph, type SignalDebugNg } from '../signal-graph.ts';
-import { angularAtLeast } from './angular-version.ts';
+import { angularAtLeast, STANDALONE } from './angular-version.ts';
class TripStore {
readonly trips = signal(['Lisbon'], { debugName: 'trips' });
@@ -69,6 +69,7 @@ class TripPage {
}
Component({
+ ...STANDALONE,
selector: 'app-root',
template: `{{ city() }} {{ weather.value() }} {{ weather.status() }} {{ forecast.error() }}
{{ unnamed.value() }} {{ airport.value() }} {{ airport.statusCode() }} {{ selected() }}`,
diff --git a/packages/devtools/src/__tests__/signal-history.test.ts b/packages/devtools/src/__tests__/signal-history.test.ts
index 3de3d88a..334afa24 100644
--- a/packages/devtools/src/__tests__/signal-history.test.ts
+++ b/packages/devtools/src/__tests__/signal-history.test.ts
@@ -361,6 +361,12 @@ describe('installSignalWriteHook', () => {
expect(await installSignalWriteHook(vi.fn(), async () => ({}) as never)).toBeNull();
});
+ it('returns null with the stub the Vite plugin uses before Angular 17', async () => {
+ expect(
+ await installSignalWriteHook(vi.fn(), () => import('../signals-stub.ts') as never),
+ ).toBeNull();
+ });
+
it('returns a restore function when the hook installs', async () => {
expect(await installSignalWriteHook(vi.fn(), async () => fakeCore())).toBeTypeOf('function');
});
diff --git a/packages/devtools/src/__tests__/vite-signals-stub.test.ts b/packages/devtools/src/__tests__/vite-signals-stub.test.ts
new file mode 100644
index 00000000..5e718a19
--- /dev/null
+++ b/packages/devtools/src/__tests__/vite-signals-stub.test.ts
@@ -0,0 +1,49 @@
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import pangularVite from '../vite.ts';
+import {
+ SIGNAL_PRIMITIVES,
+ angularCoreMajor,
+ signalPrimitivesAlias,
+} from '../vite-signals-stub.ts';
+
+/** An app folder whose node_modules holds `@angular/core` at `version`, or nothing. */
+function app(version?: string): string {
+ const root = mkdtempSync(join(tmpdir(), 'pangular-signals-'));
+ writeFileSync(join(root, 'package.json'), '{"name":"app","private":true}');
+ if (version) {
+ const core = join(root, 'node_modules', '@angular', 'core');
+ mkdirSync(core, { recursive: true });
+ writeFileSync(join(core, 'package.json'), JSON.stringify({ name: '@angular/core', version }));
+ }
+ return root;
+}
+
+describe('the signal primitives stub for Angular before 17', () => {
+ it('reads the installed @angular/core major', () => {
+ expect(angularCoreMajor(app('15.2.10'))).toBe(15);
+ expect(angularCoreMajor(app('17.3.12'))).toBe(17);
+ expect(angularCoreMajor(app())).toBeUndefined();
+ });
+
+ it('points the primitives entry at the stub only before 17.0', () => {
+ const [alias] = signalPrimitivesAlias(app('16.2.12'));
+ expect(alias?.find).toBe(SIGNAL_PRIMITIVES);
+ expect(String(alias?.replacement)).toMatch(/signals-stub\.(ts|mjs)$/);
+ expect(signalPrimitivesAlias(app('12.2.17'))).toHaveLength(1);
+ expect(signalPrimitivesAlias(app('17.0.0'))).toEqual([]);
+ expect(signalPrimitivesAlias(app('22.1.7'))).toEqual([]);
+ expect(signalPrimitivesAlias(app())).toEqual([]);
+ });
+
+ it('adds the alias through the Vite plugin config hook', () => {
+ const plugin = pangularVite();
+ const config = plugin.config as (user: { root?: string }) => unknown;
+ expect(config({ root: app('15.2.10') })).toMatchObject({
+ resolve: { alias: [{ find: SIGNAL_PRIMITIVES }] },
+ });
+ expect(config({ root: app('20.0.7') })).toBeUndefined();
+ });
+});
diff --git a/packages/devtools/src/forms.ts b/packages/devtools/src/forms.ts
index 2240acb5..66cab960 100644
--- a/packages/devtools/src/forms.ts
+++ b/packages/devtools/src/forms.ts
@@ -293,9 +293,10 @@ function read(fn: () => T, fallback: T): T {
export function isAbstractControl(value: unknown): value is AnyRecord {
if (!value || typeof value !== 'object') return false;
const v = value as AnyRecord;
+ // `FormControl.getRawValue` only exists from 14.0, so it can't tell controls apart.
return read(
() =>
- typeof v['getRawValue'] === 'function' &&
+ typeof v['setValue'] === 'function' &&
typeof v['setParent'] === 'function' &&
typeof v['updateValueAndValidity'] === 'function' &&
'status' in v,
diff --git a/packages/devtools/src/http-cache-key.ts b/packages/devtools/src/http-cache-key.ts
index e6c6a42f..7337c9d5 100644
--- a/packages/devtools/src/http-cache-key.ts
+++ b/packages/devtools/src/http-cache-key.ts
@@ -103,5 +103,8 @@ export function transferCacheKeys(req: HttpRequest): string[] {
keys.add(sha256Hex(parts.join('|')));
keys.add(legacyHash(parts.join('|')));
}
+ // 16.0 to 17.0 leave the body out: `method.responseType.url?params`.
+ const params = legacyParams(req.params as ParamsLike);
+ keys.add(legacyHash(`${req.method}.${req.responseType}.${req.url}?${params}`));
return [...keys];
}
diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts
index d49e65bd..f980846d 100644
--- a/packages/devtools/src/ngrx-collector.ts
+++ b/packages/devtools/src/ngrx-collector.ts
@@ -1,4 +1,4 @@
-import { untracked } from '@angular/core';
+import * as angularCore from '@angular/core';
import { isRedactedKey, REDACTED } from './forms-privacy.ts';
import { documentTree, type HostTree } from './host-tree.ts';
import { className, tokenName } from './injector-tree.ts';
@@ -21,6 +21,16 @@ import {
} from './ngrx-shared.ts';
import { registeredPatchState, registeredWatchState } from './ngrx-register.ts';
+/**
+ * Angular's `untracked` (16.0+), read at runtime: a named import of it fails
+ * the build of an app on Angular 12 to 15 under webpack's strict ESM rules.
+ * Signals don't exist before 16.0, so reading the value directly is the same.
+ */
+function untracked(body: () => T): T {
+ const fn = Reflect.get(angularCore, 'untracked') as ((body: () => T) => T) | undefined;
+ return typeof fn === 'function' ? fn(body) : body();
+}
+
type AnyRecord = Record;
export interface NgrxDebugNg {
diff --git a/packages/devtools/src/router-actions.ts b/packages/devtools/src/router-actions.ts
index be4effeb..7997e2ad 100644
--- a/packages/devtools/src/router-actions.ts
+++ b/packages/devtools/src/router-actions.ts
@@ -2,6 +2,7 @@ import { isRouterLink } from './router-links.ts';
import {
CANCEL_CODES,
EventType,
+ routerEventType,
MAX_DEPTH,
clip,
componentName,
@@ -112,7 +113,7 @@ export function onGuardsCheckStart(router: AnyRecord, run: () => void): () => vo
const subscription = read(
() =>
events?.['subscribe']((event: AnyRecord) => {
- if (read(() => event['type'] === EventType.GuardsCheckStart, false)) run();
+ if (routerEventType(event) === EventType.GuardsCheckStart) run();
}) as { unsubscribe(): void } | undefined,
undefined,
);
@@ -600,17 +601,33 @@ function waitForNavigation(
});
}
+/** Whether `value` can be subscribed to, like an RxJS subject. */
+function subscribable(value: unknown): value is AnyRecord {
+ return !!value && typeof (value as AnyRecord)['subscribe'] === 'function';
+}
+
+/**
+ * The stream of the root pending tasks the Router holds as `pendingTasks`:
+ * `hasPendingTasksObservable` from 20.0, the `hasPendingTasks` subject from
+ * 16.0 to 19.x (in 20.0 that name is a boolean). Before 16.0 the Router has none.
+ */
+function pendingTasksOf(router: AnyRecord): AnyRecord | null {
+ const tasks = read(() => router['pendingTasks'] as AnyRecord | undefined, undefined);
+ if (!tasks) return null;
+ const current = read(() => tasks['hasPendingTasksObservable'], null);
+ if (subscribable(current)) return current;
+ const legacy = read(() => tasks['hasPendingTasks'], null);
+ return subscribable(legacy) ? legacy : null;
+}
+
/**
* Waits until the app has no pending tasks, the same signal as
* `ApplicationRef.whenStable()`: both read the root `PendingTasksInternal`,
- * which the Router holds as `pendingTasks`.
+ * which the Router holds as `pendingTasks`. Null when the Router has none.
*/
export function waitForStable(router: AnyRecord, ms: number): Promise {
- const pending = read(
- () => router['pendingTasks']['hasPendingTasksObservable'] as AnyRecord,
- null,
- );
- if (typeof pending?.['subscribe'] !== 'function') return Promise.resolve(null);
+ const pending = pendingTasksOf(router);
+ if (!pending) return Promise.resolve(null);
return new Promise((resolve) => {
let done = false;
let subscription: { unsubscribe(): void } | null = null;
diff --git a/packages/devtools/src/router-setup.ts b/packages/devtools/src/router-setup.ts
index c033b6e1..95757423 100644
--- a/packages/devtools/src/router-setup.ts
+++ b/packages/devtools/src/router-setup.ts
@@ -210,7 +210,13 @@ export function detectSetup(
: 'off';
const strategies: Record = {
- locationStrategy: strategyName(read(() => router['location']['_locationStrategy'], null)),
+ // `Location` holds it as `_platformStrategy` before 14.0 and `_locationStrategy` from it.
+ locationStrategy: strategyName(
+ read(
+ () => router['location']['_locationStrategy'] ?? router['location']['_platformStrategy'],
+ null,
+ ),
+ ),
titleStrategy: strategyName(read(() => transitions?.['titleStrategy'], null)),
routeReuseStrategy: strategyName(read(() => router['routeReuseStrategy'], null)),
urlHandlingStrategy: strategyName(read(() => router['urlHandlingStrategy'], null)),
diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts
index 85d6f2e6..3736b769 100644
--- a/packages/devtools/src/router.ts
+++ b/packages/devtools/src/router.ts
@@ -137,6 +137,27 @@ export const EventType = {
NavigationSkipped: 16,
} as const;
+/** Angular's `EventType` numbers by event class, for routers whose events have no `type`. */
+const EVENT_TYPES: Record = {
+ ...EventType,
+ ChildActivationEnd: 12,
+ ActivationEnd: 14,
+};
+
+/**
+ * The `EventType` of a router event. Events carry it as `type` from 14.0;
+ * before, every public event class names itself in `toString()`, such as
+ * `NavigationStart(id: 1, url: '/')`, which survives minification.
+ */
+export function routerEventType(event: AnyRecord): number | undefined {
+ const type = read(() => event['type'], undefined);
+ if (typeof type === 'number') return type;
+ if (type !== undefined) return undefined;
+ const text = read(() => String(event['toString']?.() ?? ''), '');
+ const name = /^([A-Za-z]+)\(/.exec(text)?.[1];
+ return name ? EVENT_TYPES[name] : undefined;
+}
+
interface NavState {
secrets: string[];
guardsAt?: number;
@@ -574,6 +595,27 @@ function isBeforeActivate(event: AnyRecord, nav: NavigationRecord | undefined):
return !!nav && stateOf(nav).recognizedAt !== undefined;
}
+/**
+ * Before 18.0 the router emits no redirect request, so a guard or resolver
+ * redirect shows only as a cancel whose reason names the target
+ * (`Redirecting to "/login"`), followed at once by the navigation to it.
+ */
+function linkCancelledRedirect(navigations: NavigationRecord[], nav: NavigationRecord): void {
+ const source = navigations[navigations.length - 1];
+ if (
+ !source ||
+ source.outcome !== 'redirected' ||
+ source.redirectTo !== undefined ||
+ source.id !== nav.id - 1 ||
+ !/^Redirecting to "/.test(source.reason ?? '')
+ ) {
+ return;
+ }
+ source.redirectTo = nav.url;
+ source.redirectKind = redirectKindOf(source);
+ nav.redirectedFrom = source.id;
+}
+
function redirectKindOf(nav: NavigationRecord): string {
const state = stateOf(nav);
if (state.resolveAt !== undefined) return 'resolver';
@@ -807,7 +849,7 @@ export function applyRouterEvent(
router?: AnyRecord,
onLateChange?: () => void,
): boolean {
- const type = read(() => event['type'] as number | undefined, undefined);
+ const type = routerEventType(event);
const id = read(() => event['id'] as number, -1);
const current = findPending(navigations);
if (type === undefined) {
@@ -872,6 +914,8 @@ export function applyRouterEvent(
if (pendingRedirect) {
nav.redirectedFrom = pendingRedirect.from;
pendingRedirect = null;
+ } else {
+ linkCancelledRedirect(navigations, nav);
}
navigations.push(nav);
return true;
@@ -1004,7 +1048,14 @@ export function applyRouterEvent(
}
case EventType.NavigationCancel: {
const code = read(() => event['code'] as number | undefined, undefined);
- nav.code = code === undefined ? undefined : (CANCEL_CODES[code] ?? String(code));
+ const rawReason = String(event['reason'] ?? '').replace(/^NavigationCancelingError: /, '');
+ // Before the cancel codes (15.x), a guard redirect shows only in its reason.
+ const legacyRedirect = code === undefined && /^Redirecting to "/.test(rawReason);
+ nav.code = legacyRedirect
+ ? 'Redirect'
+ : code === undefined
+ ? undefined
+ : (CANCEL_CODES[code] ?? String(code));
nav.outcome = nav.code === 'Redirect' ? 'redirected' : 'cancelled';
const byGuard = nav.code === 'Redirect' || nav.code === 'GuardRejected';
if (byGuard && nav.guards && nav.guards.passed === undefined) {
@@ -1018,10 +1069,7 @@ export function applyRouterEvent(
nav.reason =
nav.code === 'Redirect' && state.handled?.redirected
? state.handled.reason
- : errorText(
- String(event['reason'] ?? '').replace(/^NavigationCancelingError: /, ''),
- state.secrets,
- );
+ : errorText(rawReason, state.secrets);
finish(nav, at, router);
return true;
}
@@ -1055,10 +1103,16 @@ function urlOf(router: AnyRecord, tree: unknown): string | undefined {
/**
* The navigation that finished before the overlay subscribed, from
* `router.lastSuccessfulNavigation` (a signal since Angular 20, a property
- * before). It has no timing or guard details.
+ * before). 15.0 to 16.x keep it on `navigationTransitions` only. It has no timing or
+ * guard details.
*/
export function lastNavigationOf(router: AnyRecord, at: number): NavigationRecord | null {
- const raw = read(() => router['lastSuccessfulNavigation'], null);
+ const raw = read(
+ () =>
+ router['lastSuccessfulNavigation'] ??
+ router['navigationTransitions']?.['lastSuccessfulNavigation'],
+ null,
+ );
const last = read(() => (typeof raw === 'function' ? raw() : raw) as AnyRecord | null, null);
if (!last) return null;
const rawUrl = urlOf(router, last['extractedUrl']) ?? urlOf(router, last['initialUrl']);
diff --git a/packages/devtools/src/signal-history.ts b/packages/devtools/src/signal-history.ts
index 515e638d..04a55f71 100644
--- a/packages/devtools/src/signal-history.ts
+++ b/packages/devtools/src/signal-history.ts
@@ -277,10 +277,24 @@ export function createSignalHistory(
type SignalSetHook = ((node: RawSignalNode) => void) | null;
+/**
+ * Loads Angular's signal primitives, a package entry from 17.0. webpack is
+ * told to leave the import alone, so an Angular 12 to 16 app (which has no
+ * such entry) still builds; in a webpack bundle the import then fails at run
+ * time and history falls back to samples. Vite and esbuild resolve it as
+ * usual, and the Vite plugin stubs it out before 17.0 (`signals-stub.ts`).
+ */
+function loadSignalPrimitives(): Promise<{
+ setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook;
+}> {
+ return import(/* webpackIgnore: true */ '@angular/core/primitives/signals') as never;
+}
+
export async function installSignalWriteHook(
onWrite: (node: RawSignalNode) => void,
- load: () => Promise<{ setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook }> = () =>
- import('@angular/core/primitives/signals') as never,
+ load: () => Promise<{
+ setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook;
+ }> = loadSignalPrimitives,
): Promise<(() => void) | null> {
let setHook: (fn: SignalSetHook) => SignalSetHook;
try {
diff --git a/packages/devtools/src/signals-stub.ts b/packages/devtools/src/signals-stub.ts
new file mode 100644
index 00000000..35e29aa9
--- /dev/null
+++ b/packages/devtools/src/signals-stub.ts
@@ -0,0 +1,4 @@
+// Stands in for `@angular/core/primitives/signals` on Angular before 17.0,
+// which has no such entry: the Vite plugin points the overlay's import here,
+// and the signal write hook (signal-history.ts) then falls back to samples.
+export const setPostSignalSetFn = undefined;
diff --git a/packages/devtools/src/vite-signals-stub.ts b/packages/devtools/src/vite-signals-stub.ts
new file mode 100644
index 00000000..7b839db4
--- /dev/null
+++ b/packages/devtools/src/vite-signals-stub.ts
@@ -0,0 +1,38 @@
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { Alias } from 'vite';
+
+/** The entry the overlay's signal write hook imports (signal-history.ts). */
+export const SIGNAL_PRIMITIVES = '@angular/core/primitives/signals';
+
+/** The `@angular/core` major the app at `root` installs, or undefined when there is none. */
+export function angularCoreMajor(root: string): number | undefined {
+ try {
+ const require = createRequire(join(root, 'package.json'));
+ const file = require.resolve('@angular/core/package.json');
+ const version = (JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }).version;
+ const major = parseInt(String(version), 10);
+ return Number.isFinite(major) ? major : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** The stub module next to this one: `.ts` in the workspace, `.mjs` in the published package. */
+function stubPath(): string {
+ const file = import.meta.url.endsWith('.ts') ? './signals-stub.ts' : './signals-stub.mjs';
+ return fileURLToPath(new URL(file, import.meta.url));
+}
+
+/**
+ * Angular ships `@angular/core/primitives/signals` from 17.0. On an older app
+ * Vite could not resolve the overlay's import of it, so the plugin points it
+ * at a stub, and signal history falls back to samples. Nothing changes from 17.0.
+ */
+export function signalPrimitivesAlias(root: string): Alias[] {
+ const major = angularCoreMajor(root);
+ if (major === undefined || major >= 17) return [];
+ return [{ find: SIGNAL_PRIMITIVES, replacement: stubPath() }];
+}
diff --git a/packages/devtools/src/vite.ts b/packages/devtools/src/vite.ts
index 36da776c..a0282062 100644
--- a/packages/devtools/src/vite.ts
+++ b/packages/devtools/src/vite.ts
@@ -1,3 +1,4 @@
+import { resolve } from 'node:path';
import { isAuthTokens, type PangularAuthTokens } from './auth-tokens.ts';
import type { Plugin } from 'vite';
import { normalizeHubBase } from '@devframes/hub/constants';
@@ -22,6 +23,7 @@ import {
} from './hub-gate.ts';
import { pickPangularConfig, resolvePangularConfig, type PangularConfig } from './config.ts';
import { overlayTags } from './vite-overlay.ts';
+import { signalPrimitivesAlias } from './vite-signals-stub.ts';
import { terminalsEnabled } from './hub-terminals.ts';
export type { PangularConfig } from './config.ts';
@@ -123,6 +125,11 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin
name: 'pangular',
apply: 'serve',
enforce: 'pre',
+ config(user) {
+ // Vite allows a relative `root`, and `createRequire` needs an absolute path.
+ const alias = signalPrimitivesAlias(resolve(process.cwd(), user.root ?? ''));
+ return alias.length ? { resolve: { alias } } : undefined;
+ },
// `pre`, so Vite's own HTML pass resolves the injected import like any other.
transformIndexHtml: {
order: 'pre',
diff --git a/packages/devtools/tsdown.config.ts b/packages/devtools/tsdown.config.ts
index 1a57cc46..2a9a2967 100644
--- a/packages/devtools/tsdown.config.ts
+++ b/packages/devtools/tsdown.config.ts
@@ -15,6 +15,7 @@ export default defineConfig({
'overlay-nativescript': 'src/overlay-nativescript.ts',
'overlay-angular-native': 'src/overlay-angular-native.ts',
vite: 'src/vite.ts',
+ 'signals-stub': 'src/signals-stub.ts',
http: 'src/http.ts',
hub: 'src/hub.ts',
cli: 'src/cli.ts',
diff --git a/packages/devtools/vitest.compat.config.ts b/packages/devtools/vitest.compat.config.ts
index cfbea077..d46b1b97 100644
--- a/packages/devtools/vitest.compat.config.ts
+++ b/packages/devtools/vitest.compat.config.ts
@@ -1,12 +1,14 @@
import { createRequire } from 'node:module';
import { defineConfig, mergeConfig } from 'vitest/config';
import base from './vitest.config.ts';
+import { signalPrimitivesAlias } from './src/vite-signals-stub.ts';
const require = createRequire(import.meta.url);
const angular = Number(require('@angular/core/package.json').version.split('.')[0]);
const pageSide = [
'analog-scan',
+ 'angular-real-app',
'cd-recorder',
'cd-recorder-real',
'change-detection',
@@ -64,5 +66,8 @@ export default mergeConfig(
),
setupFiles: angular >= 21 ? [] : ['src/__tests__/zone-setup.ts'],
},
+ // What the Vite plugin does for an app before Angular 17, which has no
+ // `@angular/core/primitives/signals` for the signal write hook to import.
+ resolve: { alias: signalPrimitivesAlias(import.meta.dirname) },
}),
);