Repository navigation
fix(router): don't flash inactive on the first render of RouterLinkActive - #70828
aminesbdev wants to merge 1 commit into
Conversation
| return untracked(isActive(urlTree, router, options)); | ||
| } | ||
|
|
||
| // The router hasn't completed a navigation yet, so `isActive` (which compares |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
AGENT: Two main concerns here:
- Non-SSR (CSR) apps: In CSR, a route is not actually active until navigation completes (
NavigationEnd). Speculatively marking links active onNavigationStartmeans the navbar indicates the user is on a route (includingaria-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 theactiveclass beforeinitialNavigation()finishes, causing an Active -> Inactive -> Active flash. We should gate this pre-NavigationEndbehavior oninject(ɵIS_HYDRATION_DOM_REUSE_ENABLED, {optional: true}). - Failed or redirected initial navigations: Because
RouterLinkActivedoes not subscribe toNavigationCancel,NavigationError, orNavigationSkipped, if the initial navigation is rejected by a guard, throws in a resolver, or redirects, the link stays stuck with theactiveclass fromNavigationStart. We should bail out ofisHydratingand re-runupdate()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; |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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:- The active class is applied on
NavigationStartwhile the initial navigation is pending, preventing a hydration flash. - If the initial navigation is rejected (
canActivate: [() => false]), redirects, or errors, the active class is removed when the navigation terminates.
- The active class is applied on
202e22e to
74ffa8c
Compare
…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.
74ffa8c to
66ae338
Compare
|
AGENT: Thanks for applying the feedback so thoroughly! Stepping back and looking at why this requires so much hydration-specific event tracking in It turns out the bug is actually in
If we fix Would you be open to opening a new PR with that fix in |
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. |
|
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! |
RouterLinkActive holds back the active class until
router.navigatedis 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 routerhasn't finished a navigation yet, it compares the link against the navigation
currently in flight instead (
finalUrl, falling back toextractedUrl). ThatURL 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
navigatedis true, it behaves exactly asbefore.
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.navigatedis stillfalse, and that it stays applied once the navigation completes. That test fails
without the change. The router test suite passes locally.