diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts
index 9e4ceeb608e..5389015a083 100644
--- a/packages/router/src/directives/router_link.ts
+++ b/packages/router/src/directives/router_link.ts
@@ -130,6 +130,12 @@ import {RuntimeErrorCode} from '../errors';
* });
* ```
*
+ * ### RouterLink compatible custom elements
+ *
+ * In order to make a custom element work with routerLink, the corresponding custom
+ * element must implement the `href` attribute and must list `href` in the array of
+ * the static property/getter `observedAttributes`.
+ *
* @ngModule RouterModule
*
* @publicApi
@@ -140,13 +146,15 @@ import {RuntimeErrorCode} from '../errors';
export class RouterLink implements OnChanges, OnDestroy {
/**
* Represents an `href` attribute value applied to a host element,
- * when a host element is ``. For other tags, the value is `null`.
+ * when a host element is an ``/`` tag or a compatible custom element.
+ * For other tags, the value is `null`.
*/
href: string | null = null;
/**
* Represents the `target` attribute on a host element.
- * This is only used when the host element is an `` tag.
+ * This is only used when the host element is
+ * an ``/`` tag or a compatible custom element.
*/
@HostBinding('attr.target') @Input() target?: string;
@@ -196,7 +204,7 @@ export class RouterLink implements OnChanges, OnDestroy {
*/
@Input() relativeTo?: ActivatedRoute | null;
- /** Whether a host element is an `` tag. */
+ /** Whether a host element is an ``/`` tag or a compatible custom element. */
private isAnchorElement: boolean;
private subscription?: Subscription;
@@ -215,7 +223,20 @@ export class RouterLink implements OnChanges, OnDestroy {
private locationStrategy?: LocationStrategy,
) {
const tagName = el.nativeElement.tagName?.toLowerCase();
- this.isAnchorElement = tagName === 'a' || tagName === 'area';
+ this.isAnchorElement =
+ tagName === 'a' ||
+ tagName === 'area' ||
+ !!(
+ // Avoid breaking in an SSR context where customElements might not be defined.
+ (
+ typeof customElements === 'object' &&
+ // observedAttributes is an optional static property/getter on a custom element.
+ // The spec states that this must be an array of strings.
+ (
+ customElements.get(tagName) as {observedAttributes?: string[]} | undefined
+ )?.observedAttributes?.includes?.('href')
+ )
+ );
if (this.isAnchorElement) {
this.subscription = router.events.subscribe((s: Event) => {
diff --git a/packages/router/test/router_link_spec.ts b/packages/router/test/router_link_spec.ts
index c5ecff62f55..69d0f657252 100644
--- a/packages/router/test/router_link_spec.ts
+++ b/packages/router/test/router_link_spec.ts
@@ -207,6 +207,86 @@ describe('RouterLink', () => {
});
});
+ // Avoid executing in node environment because customElements is not defined.
+ if (typeof customElements === 'object') {
+ describe('on a custom element anchor', () => {
+ /** Simple anchor element imitation. */
+ class CustomAnchor extends HTMLElement {
+ static get observedAttributes(): string[] {
+ return ['href'];
+ }
+
+ get href(): string {
+ return this.getAttribute('href') ?? '';
+ }
+ set href(value: string) {
+ this.setAttribute('href', value);
+ }
+
+ constructor() {
+ super();
+ const shadow = this.attachShadow({mode: 'open'});
+ shadow.innerHTML = '';
+ }
+
+ attributedChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
+ if (name === 'href') {
+ const anchor = this.shadowRoot!.querySelector('a')!;
+ if (newValue === null) {
+ anchor.removeAttribute('href');
+ } else {
+ anchor.setAttribute('href', newValue);
+ }
+ }
+ }
+ }
+
+ if (!customElements.get('custom-anchor')) {
+ customElements.define('custom-anchor', CustomAnchor);
+ }
+
+ @Component({
+ template: `
+
+ `,
+ standalone: false,
+ })
+ class LinkComponent {
+ link = signal('/');
+ }
+ let fixture: ComponentFixture;
+ let link: HTMLAnchorElement;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ imports: [RouterModule.forRoot([])],
+ declarations: [LinkComponent],
+ });
+ fixture = TestBed.createComponent(LinkComponent);
+ await fixture.whenStable();
+ link = fixture.debugElement.query(By.css('custom-anchor')).nativeElement;
+ });
+
+ it('does not touch tabindex', async () => {
+ expect(link.outerHTML).not.toContain('tabindex');
+ });
+
+ it('null, removes href', async () => {
+ expect(link.outerHTML).toContain('href');
+ fixture.componentInstance.link.set(null);
+ await fixture.whenStable();
+ expect(link.outerHTML).not.toContain('href');
+ });
+
+ it('undefined, removes href', async () => {
+ expect(link.outerHTML).toContain('href');
+ fixture.componentInstance.link.set(undefined);
+ await fixture.whenStable();
+ expect(link.outerHTML).not.toContain('href');
+ });
+ });
+ }
+
it('can use a UrlTree as the input', async () => {
@Component({
template: 'link',