angular/packages/router/test/create_router_state.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

217 lines
6.7 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 {EnvironmentInjector} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {map} from 'rxjs/operators';
import {createRouterState} from '../src/create_router_state';
import {Routes} from '../src/models';
import {recognize} from '../src/recognize';
import {DefaultRouteReuseStrategy} from '../src/route_reuse_strategy';
import {RouterConfigLoader} from '../src/router_config_loader';
import {
ActivatedRoute,
advanceActivatedRoute,
createEmptyState,
RouterState,
RouterStateSnapshot,
} from '../src/router_state';
import {PRIMARY_OUTLET} from '../src/shared';
import {DefaultUrlSerializer, UrlTree} from '../src/url_tree';
import {TreeNode} from '../src/utils/tree';
describe('create router state', () => {
let reuseStrategy: DefaultRouteReuseStrategy;
beforeEach(() => {
reuseStrategy = new DefaultRouteReuseStrategy();
});
const emptyState = () => createEmptyState(RootComponent);
it('should create new state', async () => {
const state = createRouterState(
reuseStrategy,
await createState(
[
{path: 'a', component: ComponentA},
{path: 'b', component: ComponentB, outlet: 'left'},
{path: 'c', component: ComponentC, outlet: 'right'},
],
'a(left:b//right:c)',
),
emptyState(),
);
checkActivatedRoute(state.root, RootComponent);
const c = (state as any).children(state.root);
checkActivatedRoute(c[0], ComponentA);
checkActivatedRoute(c[1], ComponentB, 'left');
checkActivatedRoute(c[2], ComponentC, 'right');
});
it('should reuse existing nodes when it can', async () => {
const config = [
{path: 'a', component: ComponentA},
{path: 'b', component: ComponentB, outlet: 'left'},
{path: 'c', component: ComponentC, outlet: 'left'},
];
const prevState = createRouterState(
reuseStrategy,
await createState(config, 'a(left:b)'),
emptyState(),
);
advanceState(prevState);
const state = createRouterState(
reuseStrategy,
await createState(config, 'a(left:c)'),
prevState,
);
expect(prevState.root).toBe(state.root);
const prevC = (prevState as any).children(prevState.root);
const currC = (state as any).children(state.root);
expect(prevC[0]).toBe(currC[0]);
expect(prevC[1]).not.toBe(currC[1]);
checkActivatedRoute(currC[1], ComponentC, 'left');
});
it('should handle componentless routes', async () => {
const config = [
{
path: 'a/:id',
children: [
{path: 'b', component: ComponentA},
{path: 'c', component: ComponentB, outlet: 'right'},
],
},
];
const prevState = createRouterState(
reuseStrategy,
await createState(config, 'a/1;p=11/(b//right:c)'),
emptyState(),
);
advanceState(prevState);
const state = createRouterState(
reuseStrategy,
await createState(config, 'a/2;p=22/(b//right:c)'),
prevState,
);
expect(prevState.root).toBe(state.root);
const prevP = (prevState as any).firstChild(prevState.root)!;
const currP = (state as any).firstChild(state.root)!;
expect(prevP).toBe(currP);
const currC = (state as any).children(currP);
expect(currP._futureSnapshot.params).toEqual({id: '2', p: '22'});
expect(currP._futureSnapshot.paramMap.get('id')).toEqual('2');
expect(currP._futureSnapshot.paramMap.get('p')).toEqual('22');
checkActivatedRoute(currC[0], ComponentA);
checkActivatedRoute(currC[1], ComponentB, 'right');
});
it('should not retrieve routes when `shouldAttach` is always false', async () => {
const config: Routes = [
{path: 'a', component: ComponentA},
{path: 'b', component: ComponentB, outlet: 'left'},
{path: 'c', component: ComponentC, outlet: 'left'},
];
spyOn(reuseStrategy, 'retrieve');
const prevState = createRouterState(
reuseStrategy,
await createState(config, 'a(left:b)'),
emptyState(),
);
advanceState(prevState);
createRouterState(reuseStrategy, await createState(config, 'a(left:c)'), prevState);
expect(reuseStrategy.retrieve).not.toHaveBeenCalled();
});
it('should consistently represent future and current state', async () => {
const config: Routes = [
{path: '', pathMatch: 'full', component: ComponentA},
{path: 'product/:id', component: ComponentB},
];
spyOn(reuseStrategy, 'shouldReuseRoute').and.callThrough();
const previousState = createRouterState(
reuseStrategy,
await createState(config, ''),
emptyState(),
);
advanceState(previousState);
(reuseStrategy.shouldReuseRoute as jasmine.Spy).calls.reset();
createRouterState(reuseStrategy, await createState(config, 'product/30'), previousState);
// One call for the root and one call for each of the children
expect(reuseStrategy.shouldReuseRoute).toHaveBeenCalledTimes(2);
const reuseCalls = (reuseStrategy.shouldReuseRoute as jasmine.Spy).calls;
const future1 = reuseCalls.argsFor(0)[0];
const current1 = reuseCalls.argsFor(0)[1];
const future2 = reuseCalls.argsFor(1)[0];
const current2 = reuseCalls.argsFor(1)[1];
// Routing from '' to 'product/30'
expect(current1._routerState.url).toEqual(tree('').toString());
expect(future1._routerState.url).toEqual(tree('product/30').toString());
expect(current2._routerState.url).toEqual(tree('').toString());
expect(future2._routerState.url).toEqual(tree('product/30').toString());
});
});
function advanceState(state: RouterState): void {
advanceNode((state as any)._root);
}
function advanceNode(node: TreeNode<ActivatedRoute>): void {
advanceActivatedRoute(node.value);
node.children.forEach(advanceNode);
}
async function createState(config: Routes, url: string): Promise<RouterStateSnapshot> {
const result = await recognize(
TestBed.inject(EnvironmentInjector),
TestBed.inject(RouterConfigLoader),
RootComponent,
config,
tree(url),
new DefaultUrlSerializer(),
undefined,
new AbortController().signal,
);
return result.state;
}
function checkActivatedRoute(
actual: ActivatedRoute,
cmp: Function,
outlet: string = PRIMARY_OUTLET,
): void {
if (actual === null) {
expect(actual).toBeDefined();
} else {
expect(actual.component as any).toBe(cmp);
expect(actual.outlet).toEqual(outlet);
}
}
function tree(url: string): UrlTree {
return new DefaultUrlSerializer().parse(url);
}
class RootComponent {}
class ComponentA {}
class ComponentB {}
class ComponentC {}