Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"INPUT_SIGNAL_NODE",
"INTERNAL_APPLICATION_ERROR_HANDLER",
"INTERNAL_BROWSER_PLATFORM_PROVIDERS",
"IS_HYDRATION_DOM_REUSE_ENABLED",
"InjectionToken",
"Injector",
"InputFlags",
Expand Down
55 changes: 47 additions & 8 deletions packages/router/src/directives/router_link_active.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,27 @@ import {
Renderer2,
SimpleChanges,
untracked,
ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED,
} from '@angular/core';
import {from, of, Subscription} from 'rxjs';
import {mergeAll} from 'rxjs/operators';

import {Event, NavigationEnd} from '../events';
import {
Event,
NavigationCancel,
NavigationEnd,
NavigationError,
NavigationSkipped,
NavigationStart,
} from '../events';
import {Router} from '../router';
import {isActive, IsActiveMatchOptions, exactMatchOptions, subsetMatchOptions} from '../url_tree';
import {
containsTree,
isActive,
IsActiveMatchOptions,
exactMatchOptions,
subsetMatchOptions,
} from '../url_tree';

import {RouterLink} from './router_link';

Expand Down Expand Up @@ -133,10 +147,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
* @see {@link isActive}
*/
@Input() routerLinkActiveOptions:
| {exact: boolean}
| Partial<IsActiveMatchOptions>
| null
| undefined = {exact: false};
{exact: boolean} | Partial<IsActiveMatchOptions> | null | undefined = {exact: false};

/**
* Aria-current attribute to apply when the router link is active.
Expand Down Expand Up @@ -167,6 +178,8 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit

private link = inject(RouterLink, {optional: true});

private isHydrating = inject(IS_HYDRATION_DOM_REUSE_ENABLED, {optional: true}) ?? false;

constructor(
private router: Router,
private element: ElementRef,
Expand All @@ -175,7 +188,19 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
) {
this.routerEventsSubscription = router.events.subscribe((s: Event) => {
if (s instanceof NavigationEnd) {
this.isHydrating = false;
this.update();
} else if (this.isHydrating) {
if (s instanceof NavigationStart) {
this.update();
} else if (
s instanceof NavigationError ||
s instanceof NavigationSkipped ||
s instanceof NavigationCancel
) {
this.update();
this.isHydrating = false;
}
}
});
}
Expand Down Expand Up @@ -226,7 +251,7 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
}

private update(): void {
if (!this.links || !this.router.navigated) return;
if (!this.links || (!this.router.lastSuccessfulNavigation() && !this.isHydrating)) return;
if (this.routerLinkActiveOptions === null && !this._isActive) return;

queueMicrotask(() => {
Expand Down Expand Up @@ -285,7 +310,21 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit

return (link: RouterLink) => {
const urlTree = link.urlTree;
return urlTree ? untracked(isActive(urlTree, router, options)) : false;
if (!urlTree) return false;

if (untracked(router.lastSuccessfulNavigation)) {
return untracked(isActive(urlTree, router, options));
}

if (!this.isHydrating) {
return false;
}

// During hydration, match against the in-flight initial navigation so the
// server-rendered active class is not lost before the first NavigationEnd.
const currentNavigation = untracked(router.currentNavigation);
const targetUrl = currentNavigation?.finalUrl ?? currentNavigation?.extractedUrl;
return targetUrl ? containsTree(targetUrl, urlTree, options as IsActiveMatchOptions) : false;
};
}

Expand Down
117 changes: 115 additions & 2 deletions packages/router/test/integration/router_link_active.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
* 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 {Component, NgZone} from '@angular/core';
import {
Component,
NgZone,
ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED,
} from '@angular/core';
import {Location} from '@angular/common';
import {TestBed} from '@angular/core/testing';
import {Router, provideRouter} from '../../src';
import {Router, UrlTree, provideRouter} from '../../src';
import {By} from '@angular/platform-browser';
import {
RootCmp,
Expand Down Expand Up @@ -86,6 +90,115 @@ export function routerLinkActiveIntegrationSuite() {
expect(link.className).toEqual('active');
});

it('should not set the class while the initial navigation is blocked on a guard', async () => {
let unblockGuard!: () => void;
const guardBlocked = new Promise<boolean>((resolve) => {
unblockGuard = () => resolve(true);
});

@Component({
template:
'<router-outlet></router-outlet><a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" ></a>',
standalone: false,
})
class RootCmpWithLink {}

TestBed.configureTestingModule({declarations: [RootCmpWithLink]});
const router: Router = TestBed.inject(Router);
router.resetConfig([{path: '', component: BlankCmp, canActivate: [() => guardBlocked]}]);

const f = TestBed.createComponent(RootCmpWithLink);
await advance(f);

const link = f.nativeElement.querySelector('a');
router.initialNavigation();
await advance(f);
expect(link.className).toEqual('');

unblockGuard();
await advance(f);
expect(link.className).toEqual('active');
});

describe('during hydration', () => {
@Component({
template:
'<router-outlet></router-outlet><a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" ></a>',
standalone: false,
})
class RootCmpWithLink {}

let resolveGuard!: (result: boolean | UrlTree) => void;
let rejectGuard!: (error: Error) => void;

beforeEach(() => {
const guardBlocked = new Promise<boolean | UrlTree>((resolve, reject) => {
resolveGuard = resolve;
rejectGuard = reject;
});
TestBed.configureTestingModule({
declarations: [RootCmpWithLink],
providers: [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}],
});
TestBed.inject(Router).resetConfig([
{path: '', component: BlankCmp, canActivate: [() => guardBlocked]},
{path: 'other', component: BlankCmp},
]);
});

async function startInitialNavigation() {
const f = TestBed.createComponent(RootCmpWithLink);
await advance(f);
TestBed.inject(Router).initialNavigation();
await advance(f);
return {f, link: f.nativeElement.querySelector('a') as HTMLElement};
}

it('should set the class on NavigationStart while the initial navigation is pending', async () => {
const {f, link} = await startInitialNavigation();
expect(link.className).toEqual('active');

resolveGuard(true);
await advance(f);
expect(link.className).toEqual('active');
});

it('should remove the class when the initial navigation is rejected', async () => {
const {f, link} = await startInitialNavigation();
expect(link.className).toEqual('active');

resolveGuard(false);
await advance(f);
expect(link.className).toEqual('');
});

it('should remove the class when the initial navigation redirects', async () => {
const {f, link} = await startInitialNavigation();
expect(link.className).toEqual('active');

resolveGuard(TestBed.inject(Router).parseUrl('/other'));
await advance(f);
expect(link.className).toEqual('');
});

it('should remove the class when the initial navigation errors', async () => {
const f = TestBed.createComponent(RootCmpWithLink);
await advance(f);
const link = f.nativeElement.querySelector('a');
// `initialNavigation()` reports errors to the application error handler, so catch
// the rejection from an equivalent first navigation instead.
TestBed.inject(Router)
.navigateByUrl('/')
.catch(() => {});
await advance(f);
expect(link.className).toEqual('active');

rejectGuard(new Error('guard failed'));
await advance(f);
expect(link.className).toEqual('');
});
});

it('should set the class on a parent element when the link is active', async () => {
const router: Router = TestBed.inject(Router);
const location: Location = TestBed.inject(Location);
Expand Down
Loading