Skip to content

fix(router): don't flash inactive on the first render of RouterLinkActive - #70828

Closed
aminesbdev wants to merge 1 commit into
angular:mainfrom
aminesbdev:fix/router-link-active-initial-flash
Closed

aminesbdev wants to merge 1 commit into
angular:mainfrom
aminesbdev:fix/router-link-active-initial-flash

Conversation

@aminesbdev

@aminesbdev aminesbdev commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

RouterLinkActive holds back the active class until router.navigated is true,
which only happens after the first NavigationEnd. If the app boots on a route
that a link already points to, that link renders as inactive and then jumps to
active once the initial navigation finishes. On a navbar this shows up as a
small flash on every page load.

I changed it so the directive no longer waits for navigated. While the router
hasn't finished a navigation yet, it compares the link against the navigation
currently in flight instead (finalUrl, falling back to extractedUrl). That
URL is already known when NavigationStart fires, so a matching link gets its
class right away. The directive now also re-runs its update on NavigationStart
during that first navigation. Once navigated is true, it behaves exactly as
before.

One case is unchanged on purpose: if no navigation has started at all, there is
nothing to compare against, so the link stays inactive.

For tests, I renamed the existing "should not set the class until the first
navigation succeeds" test, since it only covers that no-navigation case. I
added one that keeps the initial navigation pending on a guard I resolve by
hand. It checks that the class is applied while router.navigated is still
false, and that it stays applied once the navigation completes. That test fails
without the change. The router test suite passes locally.

@pullapprove
pullapprove Bot requested a review from crisbeto September 20, 2026 09:43
@ngbot ngbot Bot added this to the Backlog milestone Sep 20, 2026
@JeanMeche
JeanMeche requested review from atscott and removed request for crisbeto September 20, 2026 14:18
return untracked(isActive(urlTree, router, options));
}

// The router hasn't completed a navigation yet, so `isActive` (which compares

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can and should shorten this comment quite a bit. Same with the other one. We have about twice as many lines of comments as we do lines of code changed in this PR

this.routerEventsSubscription = router.events.subscribe((s: Event) => {
if (s instanceof NavigationEnd) {
this.update();
} else if (s instanceof NavigationStart && !router.navigated) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you want to look at lastSuccessfulNavigation instead of router.navigated since router.navigated resets when you call resetConfig

this.routerEventsSubscription = router.events.subscribe((s: Event) => {
if (s instanceof NavigationEnd) {
this.update();
} else if (s instanceof NavigationStart && !router.navigated) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this behavior is also actually entirely incorrect for CSR applications where the route is not truly active and rendered until navigation completes. It also misses NavigationError and NavigationCancel that might happen on initial navigation (especially for CSR) and leaves the route in a potentially permanently active (and incorrect) state until the next navigation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. I was only thinking about the flash and missed that in CSR the link would show as active before the route is actually rendered, and could get stuck there if the first navigation fails. It's gated on hydration now, and cancel/skip/error re-run update() so the class gets dropped. Added tests for those cases too.

I think this behavior is also actually entirely incorrect for CSR applications where the route is not truly active and rendered until navigation completes. It also misses NavigationError and NavigationCancel that might happen on initial navigation (especially for CSR) and leaves the route in a potentially permanently active (and incorrect) state until the next navigation

@atscott atscott left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Left inline suggestions to gate this behavior on SSR hydration (ɵIS_HYDRATION_DOM_REUSE_ENABLED), use lastSuccessfulNavigation instead of router.navigated, and clean up active classes when an initial navigation is cancelled, skipped, or errors.

import {mergeAll} from 'rxjs/operators';

import {Event, NavigationEnd} from '../events';
import {Event, NavigationEnd, NavigationStart} from '../events';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: We should import ɵIS_HYDRATION_DOM_REUSE_ENABLED from @angular/core (similar to RouterScroller) as well as the terminating navigation events (NavigationCancel, NavigationError, NavigationSkipped) so we only apply this pre-navigation fallback during SSR hydration and clean up if the initial navigation does not succeed.

Suggested change
import {Event, NavigationEnd, NavigationStart} from '../events';
import {
Event,
NavigationCancel,
NavigationEnd,
NavigationError,
NavigationSkipped,
NavigationStart,
} from '../events';

this.routerEventsSubscription = router.events.subscribe((s: Event) => {
if (s instanceof NavigationEnd) {
this.update();
} else if (s instanceof NavigationStart && !router.navigated) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Two main concerns here:

  1. Non-SSR (CSR) apps: In CSR, a route is not actually active until navigation completes (NavigationEnd). Speculatively marking links active on NavigationStart means the navbar indicates the user is on a route (including aria-current="page") before guards/resolvers complete and while <router-outlet> has not activated it. However, in SSR with hydration, the server already rendered the link as active and client hydration (setupStaticAttributes) strips the active class before initialNavigation() finishes, causing an Active -> Inactive -> Active flash. We should gate this pre-NavigationEnd behavior on inject(ɵIS_HYDRATION_DOM_REUSE_ENABLED, {optional: true}).
  2. Failed or redirected initial navigations: Because RouterLinkActive does not subscribe to NavigationCancel, NavigationError, or NavigationSkipped, if the initial navigation is rejected by a guard, throws in a resolver, or redirects, the link stays stuck with the active class from NavigationStart. We should bail out of isHydrating and re-run update() on any terminating event:
  private isHydrating = inject(IS_HYDRATION_DOM_REUSE_ENABLED, {optional: true}) ?? false;

  constructor(
    private router: Router,
    private element: ElementRef,
    private renderer: Renderer2,
    private readonly cdr: ChangeDetectorRef,
  ) {
    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;
        }
      }
    });
  }


private update(): void {
if (!this.links || !this.router.navigated) return;
if (!this.links) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Rather than removing the navigation check altogether, we should check !this.router.lastSuccessfulNavigation() && !this.isHydrating so CSR apps do not schedule unnecessary microtasks or evaluate links before the first navigation succeeds, and so router.resetConfig() (which resets router.navigated) does not break updates.

Suggested change
if (!this.links) return;
if (!this.links || (!this.router.lastSuccessfulNavigation() && !this.isHydrating)) return;

const currentNavigation = untracked(router.currentNavigation);
const targetUrl = currentNavigation?.finalUrl ?? currentNavigation?.extractedUrl;
if (!targetUrl) return false;
return containsTree(targetUrl, urlTree, options as IsActiveMatchOptions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Use untracked(router.lastSuccessfulNavigation) instead of router.navigated (note that NavigationCancel also sets router.navigated = true even when lastSuccessfulNavigation() is null, which would cause isActive() to fall back to new UrlTree() and falsely activate / on a cancelled initial navigation). When lastSuccessfulNavigation is null, only fall back to currentNavigation while this.isHydrating is true:

      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;

});

it('should not set the class until the first navigation succeeds', async () => {
it('should not set the class before any navigation has started', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: With this gated on IS_HYDRATION_DOM_REUSE_ENABLED:

  • The original test (should not set the class until the first navigation succeeds) should be kept as-is for non-SSR/CSR mode (asserting that the link is not marked active while the initial navigation is blocked on a guard).
  • We should add hydration tests ({provide: ɵIS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}) verifying:
    1. The active class is applied on NavigationStart while the initial navigation is pending, preventing a hydration flash.
    2. If the initial navigation is rejected (canActivate: [() => false]), redirects, or errors, the active class is removed when the navigation terminates.

@aminesbdev
aminesbdev force-pushed the fix/router-link-active-initial-flash branch from 202e22e to 74ffa8c Compare September 23, 2026 05:14
@aminesbdev
aminesbdev requested a review from atscott September 23, 2026 05:20
…tive

With SSR and hydration, the server renders the active link with its
class, but hydration strips it before the initial navigation finishes,
and RouterLinkActive only puts it back on the first NavigationEnd. The
link goes active -> inactive -> active on every page load.

While hydrating, RouterLinkActive now matches links against the
in-flight initial navigation as soon as NavigationStart fires. If that
navigation is cancelled, skipped or errors, it re-evaluates the links
and stops doing so, so a link doesn't stay active for a route that was
never activated. Client-rendered apps are unchanged: links still only
become active once a navigation has succeeded.

It also checks `lastSuccessfulNavigation` instead of `router.navigated`,
which is reset by `resetConfig()` and is set to true by a cancelled
navigation.
@aminesbdev
aminesbdev force-pushed the fix/router-link-active-initial-flash branch from 74ffa8c to 66ae338 Compare September 29, 2026 05:07
@atscott

atscott commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

AGENT: Thanks for applying the feedback so thoroughly! Stepping back and looking at why this requires so much hydration-specific event tracking in RouterLinkActive, I dug into why the server-rendered active class was disappearing during hydration in the first place.

It turns out the bug is actually in @angular/core hydration rather than RouterLinkActive:

  1. Before this PR, RouterLinkActive.update() returned early on if (!this.links || !this.router.navigated) return;—it never called removeClass before NavigationEnd. In fact, if an <a> has no static class="..." attribute in the template (<a routerLink="/about" routerLinkActive="active">), tNode.classes is null and the SSR active class is not lost during hydration today.
  2. The active class only gets wiped when the element also has a static class attribute (e.g. <a class="nav-link" routerLink="/about" routerLinkActive="active">). In packages/core/src/render3/instructions/shared.ts (elementLikeStartShared), appendChild checks if (wasLastNodeCreated()) to skip DOM operations when hydrating an existing SSR element, but setupStaticAttributes runs unconditionally even when wasLastNodeCreated() is false:
    if (isElement) {
      setupStaticAttributes(lView[RENDERER], native as RElement, tNode);
      ...
    }
    if (wasLastNodeCreated() && (!isElement || !isDetachedByI18n(tNode))) {
      appendChild(tView, lView, native, tNode);
    }
  3. Inside setupStaticAttributes, writeDirectClass calls renderer.setAttribute(element, 'class', newValue) (and writeDirectStyle calls renderer.setAttribute(element, 'style', newValue)), which was written assuming a newly created empty DOM element. During hydration, this overwrites the class attribute with only tNode.classes ("nav-link"), clobbering "active" (as well as any classes or styles added via Renderer2.addClass / Renderer2.setStyle during SSR by any other directive).

If we fix elementLikeStartShared / setupStaticAttributes in @angular/core so it does not overwrite static attributes/classes/styles on already-created SSR DOM nodes when !wasLastNodeCreated(), the SSR active class will remain intact through hydration until NavigationEnd without needing any IS_HYDRATION_DOM_REUSE_ENABLED or NavigationStart workaround in RouterLinkActive (and it will fix the same hydration flash for all other directives that use Renderer2.addClass).

Would you be open to opening a new PR with that fix in @angular/core (and we can convert this PR to draft in the meantime)?

@aminesbdev

Copy link
Copy Markdown
Contributor Author

AGENT: Thanks for applying the feedback so thoroughly! Stepping back and looking at why this requires so much hydration-specific event tracking in RouterLinkActive, I dug into why the server-rendered active class was disappearing during hydration in the first place.

It turns out the bug is actually in @angular/core hydration rather than RouterLinkActive:

  1. Before this PR, RouterLinkActive.update() returned early on if (!this.links || !this.router.navigated) return;—it never called removeClass before NavigationEnd. In fact, if an <a> has no static class="..." attribute in the template (<a routerLink="/about" routerLinkActive="active">), tNode.classes is null and the SSR active class is not lost during hydration today.
  2. The active class only gets wiped when the element also has a static class attribute (e.g. <a class="nav-link" routerLink="/about" routerLinkActive="active">). In packages/core/src/render3/instructions/shared.ts (elementLikeStartShared), appendChild checks if (wasLastNodeCreated()) to skip DOM operations when hydrating an existing SSR element, but setupStaticAttributes runs unconditionally even when wasLastNodeCreated() is false:
    if (isElement) {
      setupStaticAttributes(lView[RENDERER], native as RElement, tNode);
      ...
    }
    if (wasLastNodeCreated() && (!isElement || !isDetachedByI18n(tNode))) {
      appendChild(tView, lView, native, tNode);
    }
  3. Inside setupStaticAttributes, writeDirectClass calls renderer.setAttribute(element, 'class', newValue) (and writeDirectStyle calls renderer.setAttribute(element, 'style', newValue)), which was written assuming a newly created empty DOM element. During hydration, this overwrites the class attribute with only tNode.classes ("nav-link"), clobbering "active" (as well as any classes or styles added via Renderer2.addClass / Renderer2.setStyle during SSR by any other directive).

If we fix elementLikeStartShared / setupStaticAttributes in @angular/core so it does not overwrite static attributes/classes/styles on already-created SSR DOM nodes when !wasLastNodeCreated(), the SSR active class will remain intact through hydration until NavigationEnd without needing any IS_HYDRATION_DOM_REUSE_ENABLED or NavigationStart workaround in RouterLinkActive (and it will fix the same hydration flash for all other directives that use Renderer2.addClass).

Would you be open to opening a new PR with that fix in @angular/core (and we can convert this PR to draft in the meantime)?

Thanks for digging into this. I opened #71200: elementLikeStartShared now only calls setupStaticAttributes when the node was just created. I also checked it with a RouterLinkActive link on a lazy route, and the server-rendered active class stays through hydration until NavigationEnd with the directive unchanged. I'll convert this one to draft in the meantime.

@angular-robot

angular-robot Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

This draft PR is being closed because it has been stale for 28 days and has seen no activity from you. If you'd like to see this change land, you can re-open this PR. Thank you for being an Angular contributor!

@angular-robot angular-robot Bot closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RouterLinkActive initial render state prior to first router navigation

2 participants