Skip to content

Commit 3683902

Browse files
authored
feat(router): adds browserUrl input support to router links
Enables specifying a custom browser URL for router links via a new input, allowing navigation to use an explicit browser URL in navigation options.
1 parent 306f367 commit 3683902

File tree

4 files changed

+76
-1
lines changed

4 files changed

+76
-1
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ export type RouterHashLocationFeature = RouterFeature<RouterFeatureKind.RouterHa
795795
// @public
796796
class RouterLink implements OnChanges, OnDestroy {
797797
constructor(router: Router, route: ActivatedRoute, tabIndexAttribute: string | null | undefined, renderer: Renderer2, el: ElementRef, locationStrategy?: LocationStrategy | undefined);
798+
browserUrl: i0.InputSignal<string | UrlTree | undefined>;
798799
set fragment(value: string | undefined);
799800
// (undocumented)
800801
get fragment(): string | undefined;
@@ -845,7 +846,7 @@ class RouterLink implements OnChanges, OnDestroy {
845846
// (undocumented)
846847
get urlTree(): UrlTree | null;
847848
// (undocumented)
848-
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterLink, "[routerLink]", never, { "target": { "alias": "target"; "required": false; }; "queryParams": { "alias": "queryParams"; "required": false; }; "fragment": { "alias": "fragment"; "required": false; }; "queryParamsHandling": { "alias": "queryParamsHandling"; "required": false; }; "state": { "alias": "state"; "required": false; }; "info": { "alias": "info"; "required": false; }; "relativeTo": { "alias": "relativeTo"; "required": false; }; "preserveFragment": { "alias": "preserveFragment"; "required": false; }; "skipLocationChange": { "alias": "skipLocationChange"; "required": false; }; "replaceUrl": { "alias": "replaceUrl"; "required": false; }; "routerLink": { "alias": "routerLink"; "required": false; }; }, {}, never, never, true, never>;
849+
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterLink, "[routerLink]", never, { "target": { "alias": "target"; "required": false; }; "queryParams": { "alias": "queryParams"; "required": false; }; "fragment": { "alias": "fragment"; "required": false; }; "queryParamsHandling": { "alias": "queryParamsHandling"; "required": false; }; "state": { "alias": "state"; "required": false; }; "info": { "alias": "info"; "required": false; }; "relativeTo": { "alias": "relativeTo"; "required": false; }; "preserveFragment": { "alias": "preserveFragment"; "required": false; }; "skipLocationChange": { "alias": "skipLocationChange"; "required": false; }; "replaceUrl": { "alias": "replaceUrl"; "required": false; }; "browserUrl": { "alias": "browserUrl"; "required": false; "isSignal": true; }; "routerLink": { "alias": "routerLink"; "required": false; }; }, {}, never, never, true, never>;
849850
// (undocumented)
850851
static ɵfac: i0.ɵɵFactoryDeclaration<RouterLink, [null, null, { attribute: "tabindex"; }, null, null, null]>;
851852
}

packages/router/src/directives/router_link.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
untracked,
2929
ɵINTERNAL_APPLICATION_ERROR_HANDLER,
3030
ɵRuntimeError as RuntimeError,
31+
input,
3132
} from '@angular/core';
3233
import {Subject} from 'rxjs';
3334

@@ -353,6 +354,14 @@ export class RouterLink implements OnChanges, OnDestroy {
353354
}
354355
private _replaceUrl = signal<boolean>(false);
355356

357+
/**
358+
* Passed to {@link Router#navigateByUrl} as part of the
359+
* `NavigationBehaviorOptions`.
360+
* @see {@link NavigationBehaviorOptions#browserUrl}
361+
* @see {@link Router#navigateByUrl}
362+
*/
363+
browserUrl = input<UrlTree | string | undefined>(undefined);
364+
356365
/**
357366
* Whether a host element is an `<a>`/`<area>` tag or a compatible custom
358367
* element.
@@ -490,11 +499,15 @@ export class RouterLink implements OnChanges, OnDestroy {
490499
}
491500
}
492501

502+
const browserUrl = this.browserUrl();
493503
const extras = {
494504
skipLocationChange: this.skipLocationChange,
495505
replaceUrl: this.replaceUrl,
496506
state: this.state,
497507
info: this.info,
508+
// TODO: Remove conditional spread once all consumers handle `browserUrl`.
509+
// Having this property always set broke some tests in G3.
510+
...(browserUrl !== undefined && {browserUrl}),
498511
};
499512
// navigateByUrl is mocked frequently in tests... Reduce breakages when
500513
// adding `catch`

packages/router/test/integration/integration_helpers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ export class LinkWithState {}
142142
})
143143
export class DivLinkWithState {}
144144

145+
@Component({
146+
selector: 'link-cmp',
147+
template: `<a id="link" [routerLink]="['../simple']" [browserUrl]="'/custom'">link</a>`,
148+
standalone: false,
149+
})
150+
export class LinkWithBrowserUrl {}
151+
152+
@Component({
153+
selector: 'div-link-cmp',
154+
template: `<div id="link" [routerLink]="['../simple']" [browserUrl]="'/custom'">link</div>`,
155+
standalone: false,
156+
})
157+
export class DivLinkWithBrowserUrl {}
158+
145159
@Component({
146160
selector: 'simple-cmp',
147161
template: `simple`,
@@ -433,6 +447,8 @@ export class LazyComponent {}
433447
LinkWithQueryParamsAndFragment,
434448
DivLinkWithState,
435449
LinkWithState,
450+
DivLinkWithBrowserUrl,
451+
LinkWithBrowserUrl,
436452
CollectParamsCmp,
437453
QueryParamsAndFragmentCmp,
438454
StringLinkButtonCmp,
@@ -465,6 +481,8 @@ export class LazyComponent {}
465481
LinkWithQueryParamsAndFragment,
466482
DivLinkWithState,
467483
LinkWithState,
484+
DivLinkWithBrowserUrl,
485+
LinkWithBrowserUrl,
468486
CollectParamsCmp,
469487
QueryParamsAndFragmentCmp,
470488
StringLinkButtonCmp,

packages/router/test/integration/router_links.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
DivLinkWithState,
2727
createRoot,
2828
advance,
29+
DivLinkWithBrowserUrl,
30+
LinkWithBrowserUrl,
2931
} from './integration_helpers';
3032

3133
export function routerLinkIntegrationSpec() {
@@ -422,6 +424,47 @@ export function routerLinkIntegrationSpec() {
422424
expect(location.getState()).toEqual({foo: 'bar', navigationId: 3});
423425
});
424426
});
427+
describe('should support browserUrl', () => {
428+
let component: typeof LinkWithBrowserUrl | typeof DivLinkWithBrowserUrl;
429+
it('for anchor elements', () => {
430+
// Test logic in afterEach to reduce duplication
431+
component = LinkWithBrowserUrl;
432+
});
433+
434+
it('for non-anchor elements', () => {
435+
// Test logic in afterEach to reduce duplication
436+
component = DivLinkWithBrowserUrl;
437+
});
438+
439+
afterEach(async () => {
440+
const router: Router = TestBed.inject(Router);
441+
const location: Location = TestBed.inject(Location);
442+
const fixture = await createRoot(router, RootCmp);
443+
444+
router.resetConfig([
445+
{
446+
path: 'team/:id',
447+
component: TeamCmp,
448+
children: [
449+
{path: 'link', component},
450+
{path: 'simple', component: SimpleCmp},
451+
],
452+
},
453+
]);
454+
455+
router.navigateByUrl('/team/22/link');
456+
await advance(fixture);
457+
458+
const native = fixture.nativeElement.querySelector('#link');
459+
native.click();
460+
await advance(fixture);
461+
462+
expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]');
463+
464+
// Check the browser URL is the custom browserUrl value
465+
expect(location.path()).toEqual('/custom');
466+
});
467+
});
425468

426469
it('should set href on area elements', async () => {
427470
@Component({

0 commit comments

Comments
 (0)