It is undeniable that the angular/router package is full of useful features. This time, instead of focusing on an a single and precise topic, we're going to look at some interesting facts and properties of this package that you might not be aware of. These can range from sorts of comparisons(e.g relative vs absolute redirects) to nonobvious details(e.g RouterOutlet's hierarchy; how the URL is set in the browser etc).

This article assumes the reader has some basic knowledge of Angular Router(e.g. route navigations, outlets). By the end of it, you should have a better understanding of what this package is capable of.

Relative vs Absolute Redirects

When setting up the route configuration array, we often come across the redirectTo property. Although its purpose is defined by its name, it also has a few interesting traits that are worth examining.

The path this property takes in can either be relative or absolute. Before revealing the differences between these 2 options, let's see what configuration we'll be using:

const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    component: DefaultComponent
  },
  {
    path: 'a/b',
    component: AComponent, // reachable from `DefaultComponent`
    children: [
      {
        // Reached when `redirectTo: 'err-page'` (relative) is used
        path: 'err-page',
        component: BComponent,
      },
      {
        path: '**',
        redirectTo: 'err-page'
      },
    ],
  },
  {
    // Reached when `redirectTo: '/err-page'` is used
    path: 'err-page',
    component: DComponent,
  }
]

A StackBlitz demo can be found here.

With the current option, redirectTo: 'err-page'(relative path), the BComponent will be used. If we'd change it to /err-page, then the DComponent should be used. As a generalization, we could say that one of the difference between redirectTo: 'foo/bar' and redirectTo: '/foo/bar' is that when using an absolute path, the search for the next configuration object will start from the root, that is, the first, outermost array of routes.

const routes: Routes = [
  // **STARTS FROM HERE**
  {
    /* ... */
  },
  {
    /* ... */
    children: [
      /* ... */
      {
        path: '**',
        redirectTo: '/err-page'
      },
    ],
  },

  {
    path: 'err-page',
    /* ... */
  }
]

Whereas when using a relative path, the search will start from the first route in the array from where the redirect operation has started:

const routes: Routes = [
  {
    /* ... */
  },
  {
    /* ... */
    children: [
      // **STARTS FROM HERE**
      /* ... */
      {
        path: '**',
        redirectTo: 'err-page'
      },
    ],
  },

  {
    path: 'err-page',
    /* ... */
  }
]

Furthermore, another great feature that absolute redirects have is that they can include named outlets:

{
  path: 'a/b',
  component: AComponent,
  children: [
    {
      path: '',
      component: BComponent,
    },
    {
      path: 'c',
      outlet: 'c-outlet',
      component: CComponent,
    },
  ],
},
{
  path: 'd-route',
  redirectTo: '/a/b/(c-outlet:c)'
}

StackBlitz demo.

It is worth mentioning that an absolute redirect operation can occur only once during a route transition.

The path property which resides in the same configuration object as the redirectTo property poses a few more interesting possibilities. The path property can take in a simple string which defines a route path, or '**', making it a wildcard route. This route will match any route it is compared against. Now, let's have a look at the options a non-wildcard route gives us.

Firstly, with a non-wildcard route we can reuse the query params and the positional params(the params that follow the :nameOfParam model) from the current issued URL:

const routes: Routes = [
  {
    path: 'a/b',
    component: AComponent,
    children: [
      {
        // Reached when `redirectTo: 'err-page'` (relative) is used
        path: 'err-page',
        component: BComponent,
      },
      {
        path: 'c/:id',
        // foo=:foo - get the value of the `foo` query param that 
        // exists in the URL that against this route
        // it works for relative paths as well: `err-page/:id?errored=true&foo=:foo`
        redirectTo: '/err-page/:id?errored=true&foo=:foo'
      },
    ],
  },
  {
    // Reached when `redirectTo: '/err-page'` is used
    path: 'err-page/:id',
    component: DComponent,
  }
]

StackBlitz demo.

In the above snippet, we can see that this pattern is followed

  • ?name=:foo - the foo query param is taken from the actual url
  • path: 'a/:id', redirectTo: 'err-page/:id' - the id positional param is taken from a/:id

And here is how we'd navigate to such route:

<button routerLink="a/b/c/123" [queryParams]="{ foo: 'foovalue' }">...</button>

Also, when using a non-wildcard path and a relative redirect, that extra segments of the URL will be added to the redirectTo's segments

const routes: Routes = [
  {
    path: 'a/b',
    component: AComponent,
    children: [
      {
        path: 'err-page/test',
        component: BComponent,
      },
      {
        // `redirectTo: '/err-page'` - would lead to errors
        path: 'c',
        redirectTo: 'err-page'
      },
    ],
  },
  
  // this could never be reached from `path: 'c'`
  {
    path: 'err-page/test',
    component: DComponent,
  }
]

Note: This only works for relative redirects.

So, we can reach BComponent's route this way:

<button routerLink="a/b/c/test">...</button>

StackBlitz demo.

Things can even get a bit more complicated(and interesting), when we consider matrix params(e.g ;k1=v1;k2=v2) as well. As a side note, positional params are those which we explicitly define in the route paths(e.g /:id), whereas matrix params are taken together with their path. Internally, Angular uses entities such as UrlSegmentGroup, UrlSegment to achieve its features. If we peek at the UrlSegment's implementation, we can see mentioned matrix params. With this in mind, let's see an example:

const routes: Routes = [
  {
    path: 'd/a/:id/e',
    component: DComponent,
  },
  {
    // `redirectTo: '/d/a/:id/e'` would work as well
    path: 'a/:id', 
    redirectTo: 'd/a/:id/e'
  },
]

If we start a navigation with

<button [routerLink]="['/a', { p1: 1 }, '1', { p2: 2, p3: 3 }]">...</button>

StackBlitz demo.

the DComponent's route will be activated and will end up having this URL: .../d/a;p1=1/1;p2=2;p3=3/e

First of all, ['a/path', { p1, p2, p3 }] is the way to pass matrix params to a segment. The matrix params will be bound to the precedent path. Then, as we've learnt from the previous paragraphs, we can use positional params that are present in the current route in the redirectTo path. The important thing to notice is that the matrix params of a given segment will be preserved in the new navigation's path, if used in redirectTo.

Lastly, it is should be mentioned that wildcard routes can only reuse query params. Positional params are not possible because in order to reuse such params, they first have to find their match in the path property and since '**' is used, they can't be used any further in redirectTo.

Here's is a StackBlitz demo that illustrates how to reuse query params in a wildcard route.


Router.navigate vs Router.navigateByUrl

Although they both have the same purpose, to start a new navigation, they also have a few dissimilarities. Before revealing them, it's important to know that Angular Router operates on a UrlTree in order perform the navigation. A UrlTree can be thought of a deserialized version of a URL(a string).

navigate() will create the UrlTree needed for the navigation based on the current UrlTree. This might be a bit tricky to use, since in some cases it is needed to provide the relativeTo route as well: navigate(commandsArray, { relativeTo: ActivatedRouteInstance }). If the relativeTo option is not specified, the root ActivatedRoute will be chosen.

The navigateByUrl() method will create a new UrlTree, regardless of the current one.

If you'd like to play around with some examples, you can find them in this StackBlitz demo


How is the URL set in the browser ?

Under the hood, Angular Router simply uses the native history API. For example, when navigating to a new route, /user/:id, the history.pushState method is invoked. Similarly, it uses history.replaceState when navigating to the same path or when the replaceUrl option is set to true.

Here's a StackBlitz demo that demonstrates the behavior that can be achieved with the replaceUrl option.


The skipLocationChange option

What this options does is to ensure that the Router's method which is responsible for setting the browser's URL, thus adding items to the history's stack, will not be called. However, the Router's internal status will be updated accordingly(e.g route params, query params, anything that can be observed from ActivatedRoute).

Here you can find a StackBlitz demo.

As you can see, because this option is used, the /d will not even be shown in the address bar. Despite this, the /d route's component (DComponent) will be loaded.


The hierarchy created by the RouterOutlet directive

An Angular Router's fundamental unit is the RouterOutlet directive(identifiable as router-outlet). Without it, it would not be possible to actually show something in the browser. But, as we've seen while building Angular applications, it's not rare the case when we end up having nested router-outlettags. Speaking of that, let's assume we have a route configuration like this:

// in order to be able to see the `BarComponent`'s view, we'd need to have 2 `router-outlet`
// 1 in `app.component.html` -> needed to render `FooComponent`
// 1 in `foo.component` -> needed to render `BarComponent`

const routes = [
  {
    path: 'foo',
    component: FooComponent,
    children: [
      { path: 'bar/:id', component: BarComponent }   
    ]
  }
];

and let's also assume that we inject ActivatedRoute inside BarComponent. Have you ever wondered why, when navigating to foo/bar/123, the ActivatedRoute instance is the correct one(e.g it exposes the params, queryParams that are related to bar/:id route)? It's again an important detail that's handled by the RouterOutlet directive. In this section, we're going to explore how is this achieved(hint: it involves creating a custom injector!).

Let's consider a simpler scenario - we have a route configuration like this:

// app.module.ts
const routes = [
  {
    path: 'foo',
    component: FooComponent,
  }
]

And now, in order to be able to see the rendered view on /foo, we need to insert the router-outlet tag in app.component.html

<button routerLink="/foo">Go to /foo route</button>

<router-outlet></router-outlet>

This is where things start to get interesting. Let's see what the first steps of initialization are:

constructor(
    private parentContexts: ChildrenOutletContexts, private location: ViewContainerRef,
    private resolver: ComponentFactoryResolver, @Attribute('name') name: string,
    private changeDetector: ChangeDetectorRef) {
  // in case we're using named outlet, we provide the `name` property
  // as we can see, it defaults to `PRIMARY_OUTLET`(`primary`)
  this.name = name || PRIMARY_OUTLET;
  parentContexts.onChildOutletCreated(this.name, this);
}

We can already see something which is not that common: ChildrenOutletContexts. Let's see what it is about:

export class ChildrenOutletContexts {
  // contexts for child outlets, by name.
  private contexts = new Map<string, OutletContext>();

  /** Called when a `RouterOutlet` directive is instantiated */
  onChildOutletCreated(childName: string, outlet: RouterOutlet): void {
    const context = this.getOrCreateContext(childName);
    context.outlet = outlet;
    this.contexts.set(childName, context);
  }
  /* ... */

  getOrCreateContext(childName: string): OutletContext {
    let context = this.getContext(childName);

    if (!context) {
      context = new OutletContext();
      this.contexts.set(childName, context);
    }

    return context;
  }

  getContext(childName: string): OutletContext|null {
    return this.contexts.get(childName) || null;
  }
}

export class OutletContext {
  outlet: RouterOutlet|null = null;
  route: ActivatedRoute|null = null;
  resolver: ComponentFactoryResolver|null = null;
  children = new ChildrenOutletContexts();
  attachRef: ComponentRef<any>|null = null;
}

So, when any RouterDirective is created, it will right away invoke ChildrenOutletContexts.onChildOutletCreated(). Then, it will either reuse an existing context or it create a new one, which is the current situation. We've introduced the context concept and it can be accurately described by the OutletContext. An interesting thing to notice here is that a context has a children property, which points ChildrenOutletContexts. What this means is that we can visualize this process as a tree of contexts, more exactly a tree of OutletContext instances.

What you might wonder now is why the ChildrenOutletContexts class needs to maintains a map of OutletContexts:

private contexts = new Map<string, OutletContext>();

Even if this question didn't not immediately come to mind, it's a question that's worth asking. To answer this, let's recall that we can also have named outlets. So, what would the context Map instance look like if we would add these? :

const routes = [
  {
    path: 'foo',
    component: FooComponent,
  },
  {
    path: 'bar',
    component: BarComponent,
    outlet: 'named-bar'
  }
]
<!-- app.component.html -->
<button routerLink="/foo">Go to /foo route</button>
<button [routerLink]="[{ outlets: { named-bar: bar } }]">Go to /bar route - named outlet</button>

<router-outlet></router-outlet>
<router-outlet name="named-bar"></router-outlet>

This time, the main ChildrenOutletContexts class will have its onChildOutletCreated called twice, each time a new OutletContext will be created. So, the context will be as follows:

{
  primary: OutletContext,
  'named-bar': OutletContext
}

If we were to use the tree's parlance, we'd say that the context map's entries represents a level of a tree and each of its values would go one level deeper.

Let's sharpen this newly learned concept with a help of a StackBlitz demo, with the help of which we'll be able to see that this hierarchy is about:

You can visualize it by checking the console's output after clicking the initial button. This might also serve as a debugging technique in case something seems to not go right with your routes.

Now that are more familiar with the RouterOutlet's hierarchy, we can now find out what makes possible the ActivatedRoute to be scoped to a certain route.

At the end of the file where RouterOutlet is implemented, there is something that's worth some attention:

class OutletInjector implements Injector {
  constructor(
      private route: ActivatedRoute, private childContexts: ChildrenOutletContexts,
      private parent: Injector) {}

  get(token: any, notFoundValue?: any): any {
    if (token === ActivatedRoute) {
      return this.route;
    }

    if (token === ChildrenOutletContexts) {
      return this.childContexts;
    }

    return this.parent.get(token, notFoundValue);
  }
}

If we take a look at how RouterOutlet renders something to the screen, we'd see how OutletInjector is used:

const injector = new OutletInjector(activatedRoute, childContexts, this.location.injector);
// this.location - `ViewContainerRef`
this.activated = this.location.createComponent(factory, this.location.length, injector);

This is what allows us to always get the proper ActivatedRoute, when required. When a component injects ActivatedRoute, it will look up the injector tree until it finds the first occurrence of the token. The scope is actually created when RouterOutlet creates a new view. As we can see in OutletInjector's implementation, when the ActivatedRoute token is required, it will provide the activatedRoute that was received when the injector was created.


Is it necessary to unsubscribe from ActivatedRoute's properties ?

The short answer is no.

Here's how an ActivatedRoute is created:

function createActivatedRoute(c: ActivatedRouteSnapshot) {
  return new ActivatedRoute(
      new BehaviorSubject(c.url), new BehaviorSubject(c.params), new BehaviorSubject(c.queryParams),
      new BehaviorSubject(c.fragment), new BehaviorSubject(c.data), c.outlet, c.component, c);
}

Assuming you have a configuration that looks like this:

{
  path: 'a/:id',
  component: AComponent,
  children: [
    {
      path: 'b',
      component: BComponent,
    },
    {
      path: 'c',
      component: CComponent,
    },
  ]
}

and an issued URL like a/123/b

you'd end up having a tree of ActivatedRoutes:

 APP
  |
  A
  |
  B

Whenever you schedule a navigation(e.g router.navigateToUrl()), the router has to go through some important phases:

  • apply redirects: checking for redirects, loading lazy-loaded modules, finding NoMatch errors
  • recognize: creating the ActivatedRouteSnapshot tree
  • preactivation: comparing the resulted tree with the current one; this phase also collects canActivate and canDeactivate guards, based on the differences found
  • running guards
  • create router state: where ActivatedRoute tree is created
  • activating the routes: this is the cherry on the cake and the place where the ActivatedRoute tree is leveraged

It is also important to mention the role that router-outlet plays.

As it has been described in the previous section, Angular keeps track of the router-outlets with the help of a Map object.

So, for our route configuration:

{
  path: 'a/:id',
  component: AComponent,
  children: [
    {
      path: 'b',
      component: BComponent,
    },
    {
      path: 'c',
      component: CComponent,
    },
  ]
}

the RouterOutlet's contexts Map would look like this(roughly):

{
  primary: { // Where `AComponent` resides [1]
    children: {
      // Here `AComponent`'s children reside [2]
      primary: { children: { /* ... */ } }
    }
  }
}

When a RouterOutlet is activated(it is about to render something), its activateWith method will be called. We've seen in the previous section that this is the place where the OutletInjector is created, which is what provides the scope for ActivatedRoutes:

activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver|null) {
  if (this.isActivated) {
    throw new Error('Cannot activate an already activated outlet');
  }

  this._activatedRoute = activatedRoute;
    
  /* ... */

  const injector = new OutletInjector(activatedRoute, childContexts, this.location.injector);
  this.activated = this.location.createComponent(factory, this.location.length, injector);
}

Note that this.activated holds the routed component(e.g AComponent) and this._activatedRoute holds the ActivatedRoute for this component.

Let's see now what happens when we're navigating to another route and the current view is destroyed:

deactivateRouteAndOutlet(
    route: TreeNode<ActivatedRoute>, parentContexts: ChildrenOutletContexts): void {
  const context = parentContexts.getContext(route.value.outlet);

  if (context) {
    const children: {[outletName: string]: any} = nodeChildrenAsMap(route);
    
    // from this we can also deduce that a component requires an additional `router-outlet` in this template
    // if it is part of route config. object where there is also a `children`/`loadChildren` property
    // the `route`'s `children` can also refer the routes obtained after loading a lazy module
    const contexts = route.value.component ? context.children : parentContexts;

    // Deactivate children first
    forEach(children, (v: any, k: string) => this.deactivateRouteAndItsChildren(v, contexts));

    if (context.outlet) {
      // Destroy the component
      context.outlet.deactivate();
      // Destroy the contexts for all the outlets that were in the component
      context.children.onOutletDeactivated();
    }
  }
}

where RouterOutlet.deactivate() looks like this:

deactivate(): void {
  if (this.activated) {
    const c = this.component;
    this.activated.destroy(); // Destroying the current component
    this.activated = null;
    // Nulling out the activated route - so no `complete` notification
    this._activatedRoute = null;
    this.deactivateEvents.emit(c);
  }
}

Notice that this._activatedRoute = null;, which means there is no need to unsubscribe from ActivatedRoute's observable properties. That's because these properties are BehaviorSubjects and, as we know, a Subject type maintains a list of subscribers. The memory leak may occur when the subscriber has not removed itself from the list(it can remote itself by using subscriber.unsubscribe()). But when the entity that holds everything(in this case the list of subscribers) is nulled out, it can be garbage collected, since it's no longer referenced, meaning that the subscriber which has not unsubscribed can non longer be invoked.


The paramsInheritanceStrategy option

This option can be specified as part of the ExtraOptions object when calling RouterModule.forRoot([], extraOptions) and accepts 2 values: 'emptyOnly'(default) or 'always'. When using 'emptyOnly', what is does is to allow for params and data objects to be inherited from the parent route, if the current route(not necessarily the activated one) has path: '' or if the parent route is a componentless route.

For instance, with such route configuration:

const routes: Routes = [
  {
    path: "",
    pathMatch: "full",
    component: DefaultComponent
  },
  {
    path: "a/:id",
    data: { one: 1 },
    resolve: { two: "resolveTwo" },
    // component: AComponent,
    children: [
      { path: "", data: { three: 3 }, component: BComponent },
      {
        path: "",
        data: { four: 4 },
        resolve: { five: "resolveFive" },
        component: CComponent,
        outlet: "named-c"
      }
    ]
  }
];

if we navigate to /a/123, firstly, both children routes will be activated(because both have path: ''), then both of them will inherit the data and params object from their parent: params: { id: 123, }, data: { one: 1, two: valueOfResolveTwo }. Had we uncommented the component: AComponent, line, the results would be the same, since the condition for inheritance is for the route object to either have a componentless parent route, or the route itself to have the path set to ''.

You can see the above results and experiment further in this StackBlitz demo.

Let's also examine a few other examples:

[
  {
    path: 'a',
    data: { one: 1 },
    children: [ { path: 'b', data: { two: 2 }, component: ComponentB } ]
  }
]

After navigating to a/b, the ComponentB's ActivatedRoute.data will be {one: 1, two: 2}, because the parent ActivatedRoute belongs to a componentless route.

[
  {
    path: 'a',
    component: ComponentA,
    data: { one: 1 },
    children: [ { path: 'b', data: { two: 2 }, component: ComponentB } ],
  },
]

After navigating to a/b, the ComponentB's ActivatedRoute.data will be { two: 2 }, because neither the current ActivatedRoute belongs to a path: '' route, nor the parent ActivatedRoute belongs to a componentless route. Had we set paramsInheritanceStrategy: 'always', we would get { one: 1, two: 2 }.

And lastly

[
  {
    path: 'foo/:id',
    children: [
      {
        path: 'a/:name',
        children: [
          { 
            path: 'b', 
            component: ComponentB, 
            children: [ { path: 'c', component: ComponentC } ]
          }
        ]
      }
    ]
  }
]

After navigating to foo/123/a/andrei/b/c, the ComponentB's ActivatedRoute will have the params set to { id: 123, name: 'andrei' }(its parent belongs to a componentless route and the parent of its parent does the same), whereas the ComponentC's ActivatedRoute will have params set to {}, since the route it belongs to has path: 'c' and the parent ActivatedRoute does not belong to a componentless route.


The queryParamsHandling option

This option can be specified as a property on RouterLink directive or RouterLinkWithRef directive and can accept two values: 'merge' or 'preserve'.

All the examples can be found in this StackBlitz demo.

There is also a nice tip that is related to this feature and this allows us to reuse the same view, without reloading the component, but with different queryParams:

<!-- assuming the current route has `k1='v1'` -->
<!-- after clicking the button, the same component will be used(without being reloaded) -->
<!-- but the `queryParams` this time will be those written below -->
<button [queryParams]="{ k2: 'v2', k1: 'foo-value-refreshed' }" [routerLink]="[]">...</button>

How to specify when guards and resolvers should run

One of the fascinating things about @angular/router is the amount of features and customizations it provides us with. One of them is the runGuardsAndResolvers option, which can be used in the Route configuration object:

export type RunGuardsAndResolvers =
    'pathParamsChange'|'pathParamsOrQueryParamsChange'|'paramsChange'|'paramsOrQueryParamsChange'|
    'always'|((from: ActivatedRouteSnapshot, to: ActivatedRouteSnapshot) => boolean);

Instead of using a StackBlitz application, this time we'll use some examples extracted from a few test cases. The route configuration will be this:

// runGuardsAndResolvers: RunGuardsAndResolvers = 'paramsChange' (the default value)
[
  {
    path: 'a',
    runGuardsAndResolvers,
    component: /* ... */,
    canActivate: ['guard'],
    resolve: {data: 'resolver'}
  },
]

with the mention that resolver is simply a counter, which is incremented every time its function is invoked.

Here are some examples, along with the logic that defines the test case:

router.navigateByUrl('/a');
const cmp = /* ... */; // the component associated with the `path: 'a'` route
const recordedData: any[] = [];
cmp.route.data.subscribe((data: any) => recordedData.push(data)); // the values will be of type: `{ data: counterValue }`

runGuardsAndResolvers = `paramsChange` // run guards & resolvers when either `positional params` or `matrix params` change

// since the first navigation already occurred, the resolver function was invoked once
expect(recordedData).toEqual([{data: 0}]);

// although it's the same URL, the matrix params are different, so the guards and resolvers will be invoked once again
router.navigateByUrl('/a;p=1');
expect(recordedData).toEqual([{data: 0}, {data: 1}]);

// same case the previous one
router.navigateByUrl('/a;p=2');
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);

router.navigateByUrl('/a;p=2?q=1');
// this time, nothing is changed, because only the `queryParams` have changed, but not the params
// this would've worked if `runGuardsAndResolvers` was set to `paramsOrQueryParamsChange`
// so, `paramsOrQueryParamsChange` = `paramsChange` | `queryParamsChange`
expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);

The pathParamsChange option might seem a bit confusing at first, but we might be able to clarify it a few examples:

// let's presume the counter has been reset 

// run guards & resolvers when only the positional params change
// under the hood its just comparing the URLs of 2 `ActivatedRouteSnapshot` nodes that have the same route config. object
runGuardsAndResolvers = 'pathParamsChange'

router.navigateByUrl('/a');

// `pathParamsChange` implies something like `a/1 !== a/2`
// changing any optional(matrix) params will not result in running guards or resolvers
router.navigateByUrl('/a;p=1');
expect(recordedData).toEqual([{data: 0}]);

router.navigateByUrl('/a;p=2');
expect(recordedData).toEqual([{data: 0}]);

Lastly, we have pathParamsOrQueryParamsChange which is the same as pathParamsChange from above, but it will also run the guards and the resolvers when queryParams change:

// let's presume the counter has been reset 

runGuardsAndResolvers = 'pathParamsOrQueryParamsChange'

router.navigateByUrl('/a');

// changing matrix params will not result in running guards or resolvers
router.navigateByUrl('/a;p=1');
expect(recordedData).toEqual([{data: 0}]);

router.navigateByUrl('/a;p=2');
expect(recordedData).toEqual([{data: 0}]);

// adding query params will re-run guards/resolvers
router.navigateByUrl('/a;p=2?q=1');
expect(recordedData).toEqual([{data: 0}, {data: 1}]);

Conclusion

In this article we went through many of the @angular/router's useful features. I hope it was able to answer some questions you might have got about this package and shed a light on why it is so powerful.

Thanks for reading!