It goes without saying that the angular/router package is packed with a wealth of functionality. Rather than zeroing in on one narrow subject, we'll take a look at a handful of fascinating facts and characteristics of this package that may not be immediately obvious. These can range from comparisons (e.g., relative vs. absolute redirects) to subtle details (e.g., the hierarchy of RouterOutlet; how the browser's URL actually gets updated).

Familiarity with the fundamentals of Angular Router (such as route navigations and outlets) is a prerequisite for this article. Upon finishing, you'll have a firmer grasp of the potential this package holds.

Relative Versus Absolute Redirects

While constructing the route configuration array, the redirectTo property frequently comes into play. Although its function is fairly self-explanatory, there are a few noteworthy characteristics worth delving into.

This property can point to either a relative or an absolute path. Before we highlight the differences between the two approaches, let's first establish the configuration we'll refer to:

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 working StackBlitz example can be accessed here.

With the configuration as currently set, using redirectTo: 'err-page' (a relative path) means the BComponent will be utilized. Should we switch that to /err-page, the DComponent would be the one selected. As a general rule, one contrast between redirectTo: 'foo/bar' and redirectTo: '/foo/bar' is that with an absolute path, the process of locating the next configuration object begins at the root—that is, the topmost, most external array of routes.

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

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

On the other hand, with a relative path, the search initiates from the first route in the array where the redirect was triggered:

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

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

Moreover, an additional powerful capability of absolute redirects is that they can accommodate 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's important to note that an absolute redirect can be performed just once in a given route transition.

The path property, which resides in the same configuration object as redirectTo, introduces a few more intriguing possibilities. This property can accept a simple string that defines a route path, or '**', which designates a wildcard route. Such a wildcard route matches virtually anything it's compared against. So, let's consider the options provided by a non-wildcard route.

First, with a non-wildcard route, it's feasible to reuse the query params and the positional params (the params that follow the :nameOfParam syntax) from the currently 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 snippet above, we can see this pattern realized through:

  • ?name=:foo – the foo query param is pulled directly from the actual URL
  • path: 'a/:id', redirectTo: 'err-page/:id' – the id positional param is sourced from the a/:id route

Here's how a navigation to such a route would be initiated:

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

Additionally, when pairing a non-wildcard path with a relative redirect, any extra URL segments get appended to the redirectTo 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,
  }
]

Keep in mind: This behavior is exclusive to relative redirects.

So, accessing the route for BComponent can be done in this manner:

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

StackBlitz demo.

The situation can get even more complex—and fascinating—when we bring matrix params (e.g., ;k1=v1;k2=v2) into the mix. As a quick aside, positional params are those explicitly outlined in route paths (e.g., /:id), while matrix params are captured together with their corresponding path. Behind the scenes, Angular relies on abstractions like UrlSegmentGroup and UrlSegment to implement these features. A peek at the UrlSegment's implementation shows these matrix params in action. With that context, let's dive into 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 kick off a navigation with

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

StackBlitz demo.

the route for DComponent gets triggered, ultimately producing the URL: .../d/a;p1=1/1;p2=2;p3=3/e

First, note that ['a/path', { p1, p2, p3 }] is the syntax for attaching matrix params to a segment, linking them to the preceding path. Following the earlier lessons, we can then use the positional params located in the current route within the redirectTo path. The key takeaway is that any matrix params for a given segment are retained in the new path during navigation, provided they're referenced in redirectTo.

Finally, it deserves mention that wildcard routes can only reapply query params. Positional params aren't viable because reusing them requires a match in the path property; since '**' is in effect, they can't be further utilized in redirectTo.

Here's a StackBlitz demo illustrating the reuse of query params in a wildcard route.


Router.navigate Versus Router.navigateByUrl

Even though both methods serve the same purpose—to trigger a new navigation—there are notable distinctions. It's essential to understand upfront that Angular Router relies on a UrlTree to execute navigation. Think of a UrlTree as the deserialized counterpart of a URL string.

The navigate() method constructs the necessary UrlTree for navigation based on the existing UrlTree. This can be somewhat tricky, as it sometimes requires specifying the relativeTo route: navigate(commandsArray, { relativeTo: ActivatedRouteInstance }). When relativeTo isn't provided, the root ActivatedRoute is assumed.

In contrast, navigateByUrl() builds a completely new UrlTree, independent of the current one.

For hands-on experimentation, check out the examples in this StackBlitz demo.


How Does the Browser URL Get Set?

Internally, Angular Router leverages the native History API. For instance, navigating to a new route like /user/:id triggers a call to history.pushState. Likewise, history.replaceState is invoked when navigating to the same path or when the replaceUrl option is set to true.

The StackBlitz demo here showcases the behavioral outcomes achievable with the replaceUrl option.


The skipLocationChange Option

This option guarantees that the Router's method in charge of updating the browser's URL—and thereby adding entries to the history stack—won't be invoked. Nonetheless, the Router's internal state gets updated as expected (e.g., route params, query params, everything observable from ActivatedRoute).

Check out this StackBlitz demo.

As demonstrated, due to this option being active, /d won't appear in the address bar at all. Still, the component for the /d route (DComponent) gets loaded regardless.


The hierarchy created by the RouterOutlet directive

At the heart of Angular's routing mechanism sits the RouterOutlet directive, which appears in templates as router-outlet. Without this directive, rendering routed content in the browser would be impossible. In practice, however, it's quite common to end up with nested router-outlet tags while building applications. Let's consider a route configuration like the following:

// 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 }   
    ]
  }
];

Now imagine injecting ActivatedRoute inside BarComponent. Have you ever stopped to think about why, when the URL is foo/bar/123, the ActivatedRoute instance you receive is precisely the one tied to the bar/:id route — exposing the right params and queryParams? This is yet another responsibility taken care of by the RouterOutlet directive. In this section, we'll dig into exactly how that works (spoiler: a custom injector is involved!).

Let's start with a simpler scenario, using this route setup:

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

To actually see something rendered for /foo, the router-outlet tag needs to appear in app.component.html:

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

<router-outlet></router-outlet>

This is where the interesting part begins. Let's look at the initial steps of initialization:

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);
}

Right away, we encounter something somewhat unusual: ChildrenOutletContexts. Let's take a closer look at what this class represents:

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;
}

When any RouterDirective is instantiated, it immediately calls ChildrenOutletContexts.onChildOutletCreated(). From there, it either reuses an existing context or, as in this case, creates a new one. We've just introduced the notion of a context, which is precisely captured by the OutletContext class. One particularly noteworthy aspect is that a context includes a children property pointing back to a ChildrenOutletContexts instance. In effect, this means we can think of the whole setup as a tree made up of context objects — more accurately, a tree of OutletContext instances.

You might be wondering now why the ChildrenOutletContexts class bothers to keep a map of OutletContexts:

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

Even if that question didn't cross your mind right away, it's certainly worth asking. To get an answer, recall that Angular also supports named outlets. What would the context Map look like if we introduced some?

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>

In this case, the main ChildrenOutletContexts instance will have its onChildOutletCreated method invoked twice, and each invocation results in a fresh OutletContext. So the context map ends up as:

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

If we borrow terminology from the world of trees, the entries in the context map represent a single level of the tree, and each value leads one level deeper.

To make this concept more concrete, take a look at this StackBlitz demo. By checking the console output after clicking the initial button, you'll see the hierarchy in action. This can also double as a debugging approach if your routes don't seem to behave as expected.

With a clearer picture of the RouterOutlet hierarchy in mind, we can now uncover the mechanism that scopes ActivatedRoute appropriately to each route.

Near the end of the file housing RouterOutlet, there's something worth paying attention to:

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);
  }
}

Inspecting how RouterOutlet goes about rendering content on screen shows us exactly where OutletInjector fits in:

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

This mechanism is what guarantees that the correct ActivatedRoute is always delivered when requested. When a component asks for ActivatedRoute, Angular walks up the injector tree looking for the first provider of that token. The scope itself comes into existence when RouterOutlet creates a new view. As the OutletInjector implementation shows, whenever the ActivatedRoute token is requested, it supplies the activatedRoute that was captured at the moment the injector was constructed.


Do you really need to unsubscribe from ActivatedRoute's properties?

The quick answer is no.

Here's how an ActivatedRoute gets 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);
}

Suppose you have a configuration along these lines:

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

and you navigate to a URL like a/123/b.

What you end up with is a tree of ActivatedRoute instances:

 APP
  |
  A
  |
  B

Each time you trigger a navigation — say, via router.navigateToUrl() — the router needs to move through several critical phases:

  • apply redirects: handling redirects, pulling in lazy-loaded modules, and surfacing any NoMatch errors
  • recognize: building the tree of ActivatedRouteSnapshot objects
  • preactivation: comparing the newly built tree against the existing one; this stage also gathers canActivate and canDeactivate guards based on what differs
  • running guards
  • create router state: the step where the ActivatedRoute tree is assembled
  • activating the routes: the final piece where the ActivatedRoute tree is put to use

It's also worth highlighting the contribution of router-outlet in this process.

As described in the previous section, Angular relies on a Map structure to keep tabs on all router-outlet elements.

Given our route configuration:

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

the RouterOutlet contexts Map would look something like this (simplified):

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

When a RouterOutlet is activated — meaning it's about to display something — its activateWith method is called. As we saw earlier, this is where the OutletInjector is constructed, giving ActivatedRoutes their scope:

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);
}

Remember that this.activated holds the routed component (such as AComponent), while this._activatedRoute stores the ActivatedRoute associated with that component.

Now let's see what occurs when we navigate elsewhere and the current view gets torn down:

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() is implemented 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);
  }
}

Observe the line this._activatedRoute = null; — this is why unsubscribing from ActivatedRoute's observable properties is unnecessary. Those properties are backed by BehaviorSubjects, and as we know, any Subject type keeps a list of subscribers. A memory leak could occur if a subscriber never removes itself from that list (which it can do via subscriber.unsubscribe()). However, once the object that owns everything — in this case, the subscriber list — is set to null, it becomes eligible for garbage collection, since nothing references it anymore. That means a subscriber that never unsubscribed can no longer be triggered.


The paramsInheritanceStrategy option

This option lives within the ExtraOptions object passed to RouterModule.forRoot([], extraOptions) and accepts two values: 'emptyOnly', which is the default, or 'always'. With 'emptyOnly', the params and data objects are inherited from the parent route if the route in question (not necessarily the one currently activated) has path: '', or if the parent route has no component attached.

Take this route configuration as an example:

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"
      }
    ]
  }
];

Navigating to /a/123 activates both children routes (since each has path: ''), and both then inherit the data and params from their parent: params: { id: 123, }, data: { one: 1, two: valueOfResolveTwo }. If we were to uncomment the component: AComponent, line, the outcome would stay the same — inheritance hinges on either a componentless parent route or the route's own path being ''.

You can observe these results and play around further in this StackBlitz demo.

Let's walk through a few more cases:

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

After a navigation to a/b, ComponentB's ActivatedRoute.data becomes {one: 1, two: 2}, since the parent ActivatedRoute corresponds to a route without a component.

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

Here, navigating to a/b gives ComponentB an ActivatedRoute.data of { two: 2 } — neither the current ActivatedRoute is on a path: '' route, nor does the parent ActivatedRoute belong to a componentless route. Switching to paramsInheritanceStrategy: 'always' would yield { one: 1, two: 2 } instead.

And one final illustration:

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

When navigating to foo/123/a/andrei/b/c, ComponentB's ActivatedRoute carries params equal to { id: 123, name: 'andrei' } (its parent is componentless, and so is that parent's parent), while ComponentC's ActivatedRoute reports params as {} — the route it sits on has path: 'c', and its parent ActivatedRoute does not belong to a componentless route.


The queryParamsHandling option

This option can be set on the RouterLink directive or the RouterLinkWithRef directive, and it accepts one of two values: 'merge' or 'preserve'.

All the examples below are available in this StackBlitz demo.

There's also a handy trick connected to this feature: it lets you reuse the same view without reloading the component, but with a fresh set of 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>

Controlling when guards and resolvers execute

One of the standout qualities of @angular/router is the sheer number of customization options it exposes. Among them is the runGuardsAndResolvers option, which you can include in the Route configuration object:

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

Rather than relying on a StackBlitz app this time, we'll draw on examples pulled from a handful of test cases. Here's the route configuration being used:

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

with the note that resolver is essentially a counter, bumped up each time its function runs.

Below are some scenarios, along with the logic driving each 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 value can feel a bit puzzling initially, but a few examples should clear things up:

// 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}]);

Finally, there's pathParamsOrQueryParamsChange, which behaves like pathParamsChange above but also triggers guards and resolvers when queryParams shift:

// 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}]);

Final Thoughts

Throughout this piece, we've explored a wide range of the @angular/router capabilities that often go unnoticed. Hopefully, this has clarified any uncertainties you've encountered with this library and highlighted the depth of its flexibility.

Appreciate your time!