angular/packages/router/test/bootstrap.spec.ts
Andrew Scott 5b53535dd1 fix(router): Update recognize stage to use internally async/await (#62994)
This is effectively a revert of 72e6a948bb.
Debugging the recognize stage is considerably easier with async/await
stacks compared to rxjs. This also improves maintainability and is a
better 1:1 with server-side logic that has been implemented to match
and can be more easily kept in sync.

This also ensures that the recognize step is always async, whereas it
can sometimes be synchronous with rxjs.

BREAKING CHANGE: Router navigations may take several additional
microtasks to complete. Tests have been found to often be highly
dependent on the exact timing of navigation completions with respect to
the microtask queue. The most common fix for tests is to ensure all
navigations have been completed before making assertions. On rare
occasions, this can also affect production applications. This can be
caused by multiple subscriptions to router state throughout the application,
both of which trigger navigations that happened to not conflict with the
previous timing.

PR Close #62994
2025-08-29 08:55:13 -07:00

654 lines
19 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
DOCUMENT,
PlatformLocation,
ɵgetDOM as getDOM,
BrowserPlatformLocation,
ɵNullViewportScroller as NullViewportScroller,
ViewportScroller,
} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {
ApplicationRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
destroyPlatform,
ENVIRONMENT_INITIALIZER,
inject,
Injectable,
NgModule,
provideZonelessChangeDetection,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {isNode} from '@angular/private/testing';
import {BrowserModule, platformBrowser} from '@angular/platform-browser';
import {
NavigationEnd,
provideRouter,
Router,
RouterModule,
RouterOutlet,
withEnabledBlockingInitialNavigation,
} from '../index';
import {useAutoTick} from './helpers';
// This is needed, because all files under `packages/` are compiled together as part of the
// [legacy-unit-tests-saucelabs][1] CI job, including the `lib.webworker.d.ts` typings brought in by
// [service-worker/worker/src/service-worker.d.ts][2].
//
// [1]:
// https://github.com/angular/angular/blob/ffeea63f43e6a7fd46be4a8cd5a5d254c98dea08/.circleci/config.yml#L681
// [2]:
// https://github.com/angular/angular/blob/316dc2f12ce8931f5ff66fa5f8da21c0d251a337/packages/service-worker/worker/src/service-worker.d.ts#L9
declare var window: Window;
describe('bootstrap', () => {
useAutoTick();
let log: any[] = [];
let testProviders: any[] = null!;
@Component({
template: 'simple',
standalone: false,
})
class SimpleCmp {}
@Component({
selector: 'test-app',
template: 'root <router-outlet></router-outlet>',
standalone: false,
})
class RootCmp {
constructor() {
log.push('RootCmp');
}
}
@Component({
selector: 'test-app2',
template: 'root <router-outlet></router-outlet>',
standalone: false,
})
class SecondRootCmp {}
@Injectable({providedIn: 'root'})
class TestResolver {
resolve() {
let resolve: (value: unknown) => void;
const res = new Promise((r) => (resolve = r));
setTimeout(() => resolve('test-data'), 0);
return res;
}
}
let navigationEndPromise: Promise<void>;
beforeEach(() => {
destroyPlatform();
const doc = TestBed.inject(DOCUMENT);
const oldRoots = doc.querySelectorAll('test-app,test-app2');
for (let i = 0; i < oldRoots.length; i++) {
getDOM().remove(oldRoots[i]);
}
const el1 = getDOM().createElement('test-app', doc);
const el2 = getDOM().createElement('test-app2', doc);
doc.body.appendChild(el1);
doc.body.appendChild(el2);
const {promise, resolveFn} = createPromise();
navigationEndPromise = promise;
log = [];
testProviders = [
provideZonelessChangeDetection(),
{provide: DOCUMENT, useValue: doc},
{provide: ViewportScroller, useClass: isNode ? NullViewportScroller : ViewportScroller},
{provide: PlatformLocation, useClass: MockPlatformLocation},
provideNavigationEndAction(resolveFn),
];
});
afterEach(destroyPlatform);
it('should complete when initial navigation fails and initialNavigation = enabledBlocking', async () => {
@NgModule({
imports: [BrowserModule],
declarations: [RootCmp],
bootstrap: [RootCmp],
providers: [
...testProviders,
provideRouter(
[
{
matcher: () => {
throw new Error('error in matcher');
},
children: [],
},
],
withEnabledBlockingInitialNavigation(),
),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor(router: Router) {
log.push('TestModule');
router.events.subscribe((e) => log.push(e.constructor.name));
}
}
await platformBrowser([])
.bootstrapModule(TestModule)
.then((res) => {
const router = res.injector.get(Router);
expect(router.navigated).toEqual(false);
expect(router.getCurrentNavigation()).toBeNull();
expect(router.currentNavigation()).toBeNull();
expect(log).toContain('TestModule');
expect(log).toContain('NavigationError');
});
});
it('should finish navigation when initial navigation is enabledBlocking and component renavigates on render', async () => {
@Component({
template: '',
})
class Renavigate {
constructor(router: Router) {
router.navigateByUrl('/other');
}
}
@Component({
template: '',
})
class BlankCmp {}
@NgModule({
imports: [BrowserModule, RouterOutlet],
declarations: [RootCmp],
bootstrap: [RootCmp],
providers: [
...testProviders,
provideRouter(
[
{path: '', component: Renavigate},
{path: 'other', component: BlankCmp},
],
withEnabledBlockingInitialNavigation(),
),
],
})
class TestModule {}
await expectAsync(
Promise.all([platformBrowser([]).bootstrapModule(TestModule), navigationEndPromise]),
).toBeResolved();
});
it('should wait for redirect when initialNavigation = enabledBlocking', async () => {
@Injectable({providedIn: 'root'})
class Redirect {
constructor(private router: Router) {}
canActivate() {
this.router.navigateByUrl('redirectToMe');
return false;
}
}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[
{path: 'redirectToMe', children: [], resolve: {test: TestResolver}},
{path: '**', canActivate: [Redirect], children: []},
],
{initialNavigation: 'enabledBlocking'},
),
],
declarations: [RootCmp],
bootstrap: [RootCmp],
providers: [...testProviders],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor() {
log.push('TestModule');
}
}
const bootstrapPromise = platformBrowser([])
.bootstrapModule(TestModule)
.then((ref) => {
const router = ref.injector.get(Router);
expect(router.navigated).toEqual(true);
expect(router.url).toContain('redirectToMe');
expect(log).toContain('TestModule');
});
await Promise.all([bootstrapPromise, navigationEndPromise]);
});
it('should wait for redirect with UrlTree when initialNavigation = enabledBlocking', async () => {
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[
{path: 'redirectToMe', children: []},
{
path: '**',
canActivate: [() => inject(Router).createUrlTree(['redirectToMe'])],
children: [],
},
],
{initialNavigation: 'enabledBlocking'},
),
],
declarations: [RootCmp],
bootstrap: [RootCmp],
providers: [...testProviders],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor() {
log.push('TestModule');
}
}
const bootstrapPromise = platformBrowser([])
.bootstrapModule(TestModule)
.then((ref) => {
const router = ref.injector.get(Router);
expect(router.navigated).toEqual(true);
expect(router.url).toContain('redirectToMe');
expect(log).toContain('TestModule');
});
await Promise.all([bootstrapPromise, navigationEndPromise]);
});
it('should wait for resolvers to complete when initialNavigation = enabledBlocking', async () => {
@Component({
selector: 'test',
template: 'test',
standalone: false,
})
class TestCmpEnabled {}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[{path: '**', component: TestCmpEnabled, resolve: {test: TestResolver}}],
{initialNavigation: 'enabledBlocking'},
),
],
declarations: [RootCmp, TestCmpEnabled],
bootstrap: [RootCmp],
providers: [...testProviders, TestResolver],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor(router: Router) {}
}
const bootstrapPromise = platformBrowser([])
.bootstrapModule(TestModule)
.then((ref) => {
const router = ref.injector.get(Router);
const data = router.routerState.snapshot.root.firstChild!.data;
expect(data['test']).toEqual('test-data');
// Also ensure that the navigation completed. The navigation transition clears the
// current navigation in its `finalize` operator.
expect(router.getCurrentNavigation()).toBeNull();
});
await Promise.all([bootstrapPromise, navigationEndPromise]);
});
it('should NOT wait for resolvers to complete when initialNavigation = enabledNonBlocking', async () => {
@Component({
selector: 'test',
template: 'test',
standalone: false,
})
class TestCmpLegacyEnabled {}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[{path: '**', component: TestCmpLegacyEnabled, resolve: {test: TestResolver}}],
{initialNavigation: 'enabledNonBlocking'},
),
],
declarations: [RootCmp, TestCmpLegacyEnabled],
bootstrap: [RootCmp],
providers: [...testProviders, TestResolver],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor(router: Router) {
log.push('TestModule');
router.events.subscribe((e) => log.push(e.constructor.name));
}
}
const bootstrapPromise = platformBrowser([])
.bootstrapModule(TestModule)
.then((ref) => {
const router: Router = ref.injector.get(Router);
expect(router.routerState.snapshot.root.firstChild).toBeNull();
// ResolveEnd has not been emitted yet because bootstrap returned too early
expect(log).not.toContain('ResolveEnd');
});
await Promise.all([bootstrapPromise, navigationEndPromise]);
});
it('should NOT wait for resolvers to complete when initialNavigation is not set', async () => {
@Component({
selector: 'test',
template: 'test',
standalone: false,
})
class TestCmpLegacyEnabled {}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot([
{path: '**', component: TestCmpLegacyEnabled, resolve: {test: TestResolver}},
]),
],
declarations: [RootCmp, TestCmpLegacyEnabled],
bootstrap: [RootCmp],
providers: [...testProviders, TestResolver],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor(router: Router) {
log.push('TestModule');
router.events.subscribe((e) => log.push(e.constructor.name));
}
}
const bootstrapPromise = platformBrowser([])
.bootstrapModule(TestModule)
.then((ref) => {
const router: Router = ref.injector.get(Router);
expect(router.routerState.snapshot.root.firstChild).toBeNull();
// ResolveEnd has not been emitted yet because bootstrap returned too early
expect(log).not.toContain('ResolveEnd');
});
await Promise.all([bootstrapPromise, navigationEndPromise]);
});
it('should not run navigation when initialNavigation = disabled', (done) => {
@Component({
selector: 'test',
template: 'test',
standalone: false,
})
class TestCmpDiabled {}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[{path: '**', component: TestCmpDiabled, resolve: {test: TestResolver}}],
{initialNavigation: 'disabled'},
),
],
declarations: [RootCmp, TestCmpDiabled],
bootstrap: [RootCmp],
providers: [...testProviders, TestResolver],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {
constructor(router: Router) {
log.push('TestModule');
router.events.subscribe((e) => log.push(e.constructor.name));
}
}
platformBrowser([])
.bootstrapModule(TestModule)
.then((res) => {
const router = res.injector.get(Router);
expect(log).toEqual(['TestModule', 'RootCmp']);
done();
});
});
it('should not init router navigation listeners if a non root component is bootstrapped', async () => {
@NgModule({
imports: [BrowserModule, RouterModule.forRoot([])],
declarations: [SecondRootCmp, RootCmp],
bootstrap: [RootCmp],
providers: testProviders,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {}
await platformBrowser([])
.bootstrapModule(TestModule)
.then((res) => {
const router = res.injector.get(Router);
spyOn(router as any, 'resetRootComponentType').and.callThrough();
const appRef: ApplicationRef = res.injector.get(ApplicationRef);
appRef.bootstrap(SecondRootCmp);
expect((router as any).resetRootComponentType).not.toHaveBeenCalled();
});
});
it('should reinit router navigation listeners if a previously bootstrapped root component is destroyed', async () => {
@NgModule({
imports: [BrowserModule, RouterModule.forRoot([])],
declarations: [SecondRootCmp, RootCmp],
bootstrap: [RootCmp],
providers: testProviders,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {}
await platformBrowser([])
.bootstrapModule(TestModule)
.then((res) => {
const router = res.injector.get(Router);
spyOn(router as any, 'resetRootComponentType').and.callThrough();
const appRef: ApplicationRef = res.injector.get(ApplicationRef);
const {promise, resolveFn} = createPromise();
appRef.components[0].onDestroy(() => {
appRef.bootstrap(SecondRootCmp);
expect((router as any).resetRootComponentType).toHaveBeenCalled();
resolveFn();
});
appRef.components[0].destroy();
return promise;
});
});
if (!isNode) {
it('should restore the scrolling position', async () => {
@Component({
selector: 'component-a',
template: `
<div style="height: 3000px;"></div>
<div id="marker1"></div>
<div style="height: 3000px;"></div>
<div id="marker2"></div>
<div style="height: 3000px;"></div>
<a name="marker3"></a>
<div style="height: 3000px;"></div>
`,
standalone: false,
})
class TallComponent {}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[
{path: '', pathMatch: 'full', redirectTo: '/aa'},
{path: 'aa', component: TallComponent},
{path: 'bb', component: TallComponent},
{path: 'cc', component: TallComponent},
{path: 'fail', component: TallComponent, canActivate: [() => false]},
],
{
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
scrollOffset: [0, 100],
onSameUrlNavigation: 'ignore',
},
),
],
declarations: [TallComponent, RootCmp],
bootstrap: [RootCmp],
providers: [...testProviders],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
class TestModule {}
function resolveAfter(milliseconds: number) {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, milliseconds);
});
}
const res = await platformBrowser([]).bootstrapModule(TestModule);
const router = res.injector.get(Router);
await router.navigateByUrl('/aa');
window.scrollTo(0, 5000);
await router.navigateByUrl('/fail');
expect(window.pageYOffset).toEqual(5000);
await router.navigateByUrl('/bb');
window.scrollTo(0, 3000);
expect(window.pageYOffset).toEqual(3000);
await router.navigateByUrl('/cc');
await resolveAfter(100);
expect(window.pageYOffset).toEqual(0);
await router.navigateByUrl('/aa#marker2');
await resolveAfter(100);
expect(window.scrollY).toBeGreaterThanOrEqual(5900);
expect(window.scrollY).toBeLessThan(6000); // offset
// Scroll somewhere else, then navigate to the hash again. Even though the same url navigation
// is ignored by the Router, we should still scroll.
window.scrollTo(0, 3000);
await router.navigateByUrl('/aa#marker2');
await resolveAfter(100);
expect(window.scrollY).toBeGreaterThanOrEqual(5900);
expect(window.scrollY).toBeLessThan(6000); // offset
});
it('should cleanup "popstate" and "hashchange" listeners', async () => {
@NgModule({
imports: [BrowserModule, RouterModule.forRoot([])],
declarations: [RootCmp],
bootstrap: [RootCmp],
providers: [
...testProviders,
{provide: PlatformLocation, useClass: BrowserPlatformLocation},
],
})
class TestModule {}
spyOn(window, 'addEventListener').and.callThrough();
spyOn(window, 'removeEventListener').and.callThrough();
const ngModuleRef = await platformBrowser().bootstrapModule(TestModule);
ngModuleRef.destroy();
expect(window.addEventListener).toHaveBeenCalledTimes(2);
expect(window.addEventListener).toHaveBeenCalledWith(
'popstate',
jasmine.any(Function),
jasmine.any(Boolean),
);
expect(window.addEventListener).toHaveBeenCalledWith(
'hashchange',
jasmine.any(Function),
jasmine.any(Boolean),
);
expect(window.removeEventListener).toHaveBeenCalledWith('popstate', jasmine.any(Function));
expect(window.removeEventListener).toHaveBeenCalledWith('hashchange', jasmine.any(Function));
});
}
it('can schedule a navigation from the NavigationEnd event #37460', (done) => {
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot([
{path: 'a', component: SimpleCmp},
{path: 'b', component: SimpleCmp},
]),
],
declarations: [RootCmp, SimpleCmp],
bootstrap: [RootCmp],
providers: [...testProviders],
})
class TestModule {}
(async () => {
const res = await platformBrowser([]).bootstrapModule(TestModule);
const router = res.injector.get(Router);
router.events.subscribe(async (e) => {
if (e instanceof NavigationEnd && e.url === '/b') {
await router.navigate(['a']);
done();
}
});
await router.navigateByUrl('/b');
})();
});
});
function onNavigationEnd(router: Router, fn: Function) {
router.events.subscribe((e) => {
if (e instanceof NavigationEnd) {
fn();
}
});
}
function provideNavigationEndAction(fn: Function) {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
onNavigationEnd(inject(Router), fn);
},
};
}
function createPromise() {
let resolveFn: () => void;
const promise = new Promise<void>((r) => {
resolveFn = r;
});
return {resolveFn: () => resolveFn(), promise};
}