Skip to content

Commit d085ebe

Browse files
committed
feat(router): handle null and undefined inputs in RouterLinkActive
Without this change, components that use RouterLinkActive in multiple contexts (e.g. both a navigation menu and body content) are forced to branch the template for every conditional input: @if (activeClass) { <a [routerLink]="href" [routerLinkActive]="activeClass" [routerLinkActiveOptions]="activeOptions" [ariaCurrentWhenActive]="ariaCurrent"> <ng-content /> </a> } @else { <a [routerLink]="href"><ng-content /></a> } Every additional input multiplies the branching, and each @if/@else injects unwanted comment nodes into the DOM. There is no way to conditionally attach a directive in Angular templates, making imperative TypeScript instantiation the only alternative. Accepting null/undefined collapses this to a single template branch: <a [routerLink]="href" [routerLinkActive]="activeClass" [routerLinkActiveOptions]="activeOptions" [ariaCurrentWhenActive]="ariaCurrent"> <ng-content /> </a> When activeClass is undefined (e.g. in content areas), the directive stays mounted but applies no CSS classes. When it is a string (e.g. in the navigation), normal active-class behavior applies — no branching, no extra DOM nodes, no TypeScript workarounds. - `routerLinkActive`: null/undefined now sets an empty class list. - `routerLinkActiveOptions`: null and undefined are treated differently: - undefined → falls back to the default subset match ("not set") - null → explicit opt-out, link is never considered active Closes #66233
1 parent 337e6e7 commit d085ebe

3 files changed

Lines changed: 108 additions & 14 deletions

File tree

goldens/public-api/router/index.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,10 +871,10 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
871871
ngOnChanges(changes: SimpleChanges): void;
872872
ngOnDestroy(): void;
873873
// (undocumented)
874-
set routerLinkActive(data: string[] | string);
874+
set routerLinkActive(data: string[] | string | null | undefined);
875875
routerLinkActiveOptions: {
876876
exact: boolean;
877-
} | Partial<IsActiveMatchOptions>;
877+
} | Partial<IsActiveMatchOptions> | null | undefined;
878878
// (undocumented)
879879
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterLinkActive, "[routerLinkActive]", ["routerLinkActive"], { "routerLinkActiveOptions": { "alias": "routerLinkActiveOptions"; "required": false; }; "ariaCurrentWhenActive": { "alias": "ariaCurrentWhenActive"; "required": false; }; "routerLinkActive": { "alias": "routerLinkActive"; "required": false; }; }, { "isActiveChange": "isActiveChange"; }, ["links"], never, true, never>;
880880
// (undocumented)

packages/router/src/directives/router_link_active.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,16 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
127127
*
128128
* These options are passed to the `isActive()` function.
129129
*
130+
* When `undefined`, the default subset match behavior is used.
131+
* When `null`, the link is never considered active regardless of the current URL.
132+
*
130133
* @see {@link isActive}
131134
*/
132-
@Input() routerLinkActiveOptions: {exact: boolean} | Partial<IsActiveMatchOptions> = {
133-
exact: false,
134-
};
135+
@Input() routerLinkActiveOptions:
136+
| {exact: boolean}
137+
| Partial<IsActiveMatchOptions>
138+
| null
139+
| undefined = {exact: false};
135140

136141
/**
137142
* Aria-current attribute to apply when the router link is active.
@@ -201,7 +206,11 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
201206
}
202207

203208
@Input()
204-
set routerLinkActive(data: string[] | string) {
209+
set routerLinkActive(data: string[] | string | null | undefined) {
210+
if (data == null) {
211+
this.classes = [];
212+
return;
213+
}
205214
const classes = Array.isArray(data) ? data : data.split(' ');
206215
this.classes = classes.filter((c) => !!c);
207216
}
@@ -218,6 +227,10 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
218227

219228
private update(): void {
220229
if (!this.links || !this.router.navigated) return;
230+
// Short-circuit: null options means "never active". Once _isActive has
231+
// settled to false there is nothing for subsequent navigations to do, so
232+
// skip even queuing the microtask.
233+
if (this.routerLinkActiveOptions === null && !this._isActive) return;
221234

222235
queueMicrotask(() => {
223236
const hasActiveLinks = this.hasActiveLinks();
@@ -249,14 +262,29 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
249262
}
250263

251264
private isLinkActive(router: Router): (link: RouterLink) => boolean {
252-
const options: Partial<IsActiveMatchOptions> = isActiveMatchOptions(
253-
this.routerLinkActiveOptions,
254-
)
255-
? this.routerLinkActiveOptions
256-
: // While the types should disallow `undefined` here, it's possible without strict inputs
257-
(this.routerLinkActiveOptions.exact ?? false)
258-
? {...exactMatchOptions}
259-
: {...subsetMatchOptions};
265+
const opts = this.routerLinkActiveOptions;
266+
267+
// null vs undefined are intentionally treated differently:
268+
// undefined — semantically "not set", same as omitting the input entirely,
269+
// so the default subset match applies.
270+
// null — an explicit opt-out: the caller wants the link to never be
271+
// considered active (e.g. dynamic UI where matching is not desired).
272+
if (opts === null) {
273+
return () => false;
274+
}
275+
276+
let options: Partial<IsActiveMatchOptions>;
277+
if (opts === undefined) {
278+
options = {...subsetMatchOptions};
279+
} else if (isActiveMatchOptions(opts)) {
280+
options = opts;
281+
} else if (opts.exact ?? false) {
282+
// Note: `exact` can still be undefined with non-strict template type-checking,
283+
// hence the nullish coalesce rather than a plain truthiness check.
284+
options = {...exactMatchOptions};
285+
} else {
286+
options = {...subsetMatchOptions};
287+
}
260288

261289
return (link: RouterLink) => {
262290
const urlTree = link.urlTree;

packages/router/test/router_link_active.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,72 @@ describe('RouterLinkActive', () => {
2525
expect(Array.from(fixture.nativeElement.querySelector('a').classList)).toEqual([]);
2626
});
2727

28+
it('accepts null for routerLinkActive and applies no classes', async () => {
29+
@Component({
30+
imports: [RouterLinkActive, RouterLink],
31+
template: '<a [routerLinkActive]="null" routerLink="/abc"></a>',
32+
})
33+
class MyCmp {}
34+
35+
TestBed.configureTestingModule({providers: [provideRouter([{path: '**', children: []}])]});
36+
const fixture = TestBed.createComponent(MyCmp);
37+
fixture.autoDetectChanges();
38+
await TestBed.inject(Router).navigateByUrl('/abc');
39+
await fixture.whenStable();
40+
expect(Array.from(fixture.nativeElement.querySelector('a').classList)).toEqual([]);
41+
});
42+
43+
it('accepts undefined for routerLinkActive and applies no classes', async () => {
44+
@Component({
45+
imports: [RouterLinkActive, RouterLink],
46+
template: '<a [routerLinkActive]="undefined" routerLink="/abc"></a>',
47+
})
48+
class MyCmp {}
49+
50+
TestBed.configureTestingModule({providers: [provideRouter([{path: '**', children: []}])]});
51+
const fixture = TestBed.createComponent(MyCmp);
52+
fixture.autoDetectChanges();
53+
await TestBed.inject(Router).navigateByUrl('/abc');
54+
await fixture.whenStable();
55+
expect(Array.from(fixture.nativeElement.querySelector('a').classList)).toEqual([]);
56+
});
57+
58+
it('accepts null for routerLinkActiveOptions and disables active matching', async () => {
59+
// null is an explicit opt-out: the link should never be marked active regardless
60+
// of the current URL, distinguishing it from undefined which means "use the default".
61+
@Component({
62+
imports: [RouterLinkActive, RouterLink],
63+
template:
64+
'<a routerLinkActive="active" [routerLinkActiveOptions]="null" routerLink="/abc"></a>',
65+
})
66+
class MyCmp {}
67+
68+
TestBed.configureTestingModule({providers: [provideRouter([{path: '**', children: []}])]});
69+
const fixture = TestBed.createComponent(MyCmp);
70+
fixture.autoDetectChanges();
71+
const router = TestBed.inject(Router);
72+
await router.navigateByUrl('/abc');
73+
await fixture.whenStable();
74+
expect(Array.from(fixture.nativeElement.querySelector('a').classList)).not.toContain('active');
75+
});
76+
77+
it('accepts undefined for routerLinkActiveOptions and uses default subset match', async () => {
78+
@Component({
79+
imports: [RouterLinkActive, RouterLink],
80+
template:
81+
'<a routerLinkActive="active" [routerLinkActiveOptions]="undefined" routerLink="/abc"></a>',
82+
})
83+
class MyCmp {}
84+
85+
TestBed.configureTestingModule({providers: [provideRouter([{path: '**', children: []}])]});
86+
const fixture = TestBed.createComponent(MyCmp);
87+
fixture.autoDetectChanges();
88+
const router = TestBed.inject(Router);
89+
await router.navigateByUrl('/abc');
90+
await fixture.whenStable();
91+
expect(Array.from(fixture.nativeElement.querySelector('a').classList)).toContain('active');
92+
});
93+
2894
it('supports partial match options', async () => {
2995
@Component({
3096
imports: [RouterLinkActive, RouterLink],

0 commit comments

Comments
 (0)