Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Marcos Placona
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Marcos Placona@marcos_placona )

Restoring And Resetting The Scroll Position Using The NavigationStart Event In Angular 7.0.4

By Ben Nadel on

Earlier this year, I took a stab at making a polyfill for Angular 5 that would record and restore scroll-offsets as a user navigated forwards and backwards through a Single-Page Application (SPA). In that exploration, I had to jump through a lot of hoops because Angular didn't differentiate between "imperative" navigation events and "popstate" navigation events. As of Angular 6, however, the NavigationStart event now contains navigationTrigger and popstate information that allows us to differentiate between the various modes of navigation. As such, I wanted to revisit and simplify my scroll-restoration polyfill using this newly-exposed information in Angular 7.0.4.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Back in Angular 5, I called this exploration a "Polyfill" because I had read that Angular was going to implement scroll-restoration behavior in a future release. As such, my polyfill only needed to bridge the gap between the current behavior and the future behavior. Fast-forward most of a year, however, and I see now that the term "polyfill" is misleading. Really what this is, is an opinionated Angular module that applies an augmented view of scroll retention and restoration in a data-driven, asynchronous Single-Page Application (SPA).

To be fair, Angular 6 did introduce a notion of scroll retention and restoration. However, it has a few limitations:

  • It only applies to the primary viewport. As such, it won't record scroll offsets in a secondary route's scrollable areas.
  • It cannot differentiate between navigation events in the primary and secondary router-outlets. As such, it will scroll back to the top of the page even when the primary route has not changed.
  • It cannot differentiate between navigation events that change the entire component tree and navigation events that only change a sub-section of the component tree (like a Tabbed Navigation). As such, it will scroll back to the top of the page even if the app doesn't warrant it.
  • It has no inherent understanding of asynchronous data loading. As such, it will often try to restore a scroll offset before the necessary content is available on the page. This is true even if the data is cached in-memory and only needs to be applied to the component template. In other words, this really only applies to truly static data.

Given these limitations, I think there is still room in the Angular ecosystem for a scroll retention and restoration "polyfill", even if using the term loosely. As such, let's take a look at my updated approach that builds on top of the same functionality that was added in Angular 6 in order to enable the native scroll retention and restoration behavior.

Fundamentally, my approach is the same as Angular's approach. I record scroll offsets as the user navigates imperatively through the application. I then restore those scroll offsets as the user executes "popstate" events (ie, Back and Forward). The difference - at a high level - is that I listen for "scroll" events on the entire DOM (Document Object Model) whereas Angular only looks at the scroll position of the primary viewport at navigation time. This difference is what allows me to apply scroll retention and restoration behavior to all scrollable elements.

The bulk of the logic in my "polyfill" is in the RetainScrollPolyfillService. But, it's a somewhat large service, so let's break it down by responsibility. First, the service listens for "scroll" events in the current DOM and keeps track of the scrollable DOM references:

  • // I bind to the scroll event and keep track of any elements that are scrolled in the
  • // rendered document.
  • private setupScrollBinding() : void {
  •  
  • // Add scroll-binding outside of the Angular Zone so it doesn't trigger any
  • // additional change-detection digests.
  • this.zone.runOutsideAngular(
  • () : void => {
  •  
  • // When navigating, the browser emits some scroll events as the DOM
  • // (Document Object Model) changes shape in a way that forces the various
  • // scroll offsets to change. Since these scroll events are not indicative
  • // of a user's actual scrolling intent, we're going to ignore them. This
  • // needs to be done on both sides of the navigation event (for reasons
  • // that are not fully obvious or logical -- basically, the window's
  • // scroll changes at a time that is not easy to tap into). Ignoring these
  • // scroll events is important because the polyfilly stops trying to
  • // reinstate a scroll-offset if it sees that the given element has
  • // already been scrolled during the current rendering.
  • var scrollBufferWindow = 100;
  • var target: Target | null;
  •  
  • window.addEventListener(
  • "scroll",
  • ( event: Event ) : void => {
  •  
  • // If the scroll event happens immediately following a
  • // navigation event, then ignore it - it is likely a scroll that
  • // was forced by the browser's native behavior.
  • if ( ( Date.now() - this.lastNavigationStartAt ) < scrollBufferWindow ) {
  •  
  • return;
  •  
  • }
  •  
  • // The target will return NULL for elements that have irrelevant
  • // scroll behaviors (like textarea inputs). As such, we have to
  • // check to see if the domUtils returned anything.
  • if ( target = this.domUtils.getTargetFromScrollEvent( event ) ) {
  •  
  • this.scrolledElements.add( target );
  •  
  • }
  •  
  • },
  • // We have to use the CAPTURING phase. Scroll events DO NOT BUBBLE.
  • // As such, if we want to listen for all scroll events in the
  • // document, we have to use the capturing phase (as the event travels
  • // down through the DOM tree).
  • true
  • );
  •  
  • }
  • );
  •  
  • }

As you can see, as the user scrolls various elements in the application, I grab the target from each scroll event and stuff it into a Set call scrolledElements. This could be the Window object; it could be a secondary router-outlet instance; or, any other element with some sort of overflow behavior. I use a service call DomUtils in order to encapsulate the differences between all of these target types.

Once these scroll targets are all noted, I then have to commit them to the scroll cache when the user navigates away from the current view; or, restore them from the scroll cache if the user navigates back (popstate) to a previous view. This happens through Router's event stream. Specifically, its NavigationStart and NavigationEnd events.

In the following snippet, you'll see that the NavigationStart event is where I record the current "page state". And, the NavigationEnd event is where I potentially restore a previous "page state":

  • // I bind to the router events and perform to primary actions:
  • // --
  • // NAVIGATION START: When the user is about to navigate away from the current view,
  • // I inspect the current DOM state and commit any scrolled-element offsets to the
  • // in-memory cache of the page state (scroll events were recorded during the lifetime
  • // of the current router state).
  • // --
  • // NAVIGATION END: When the user completes a navigation to a new view, I check to see
  • // if the new view is really the restoration of a previously cached page state; and,
  • // if so, I try to reinstate the old scrolled-element offsets in the rendered DOM.
  • private setupRouterBinding() : void {
  •  
  • // We need to keep track of these values across the Start / End events.
  • var navigationID: number;
  • var restoredNavigationID: number | null;
  •  
  • // The goal of the NavigationStart event is to take changes that have been made
  • // to the current DOM and store them in the render-state tree so they can be
  • // reinstated at a future date.
  • var handleNavigationStart = ( event: NavigationStart ) : void => {
  •  
  • this.lastNavigationStartAt = Date.now();
  •  
  • // Get the navigation ID and the restored navigation ID for use in the
  • // NavigationEnd event handler.
  • navigationID = event.id;
  • restoredNavigationID = ( event.restoredState )
  • ? event.restoredState.navigationId
  • : null
  • ;
  •  
  • // If the user is navigating away from the current view, kill any timers that
  • // may be trying to reinstate a page-state.
  • clearTimeout( this.applyStateToDomTimer );
  •  
  • // Before we navigate away from the current page state, let's commit any
  • // scroll-elements to the current page state.
  • Object.assign(
  • this.currentPageState,
  • this.getPageStateFromNodes( this.scrolledElements )
  • );
  •  
  • this.scrolledElements.clear();
  •  
  • // For sake of the demo.
  • console.group( "Recorded scroll offsets" );
  • for ( var selector in this.currentPageState ) {
  •  
  • console.log( selector, ":", this.currentPageState[ selector ] );
  •  
  • }
  • console.groupEnd();
  •  
  • };
  •  
  • // The primary goal of the NavigationEnd event is to reinstate a cached page
  • // state in the event that the navigation is restoring a previously rendered page
  • // as the result of a popstate event (ex, the user hit the Back or Forward
  • // buttons).
  • var handleNavigationEnd = () : void => {
  •  
  • var previousPageState = this.currentPageState;
  •  
  • // Now that we know the navigation was successful, let's start and store a
  • // new page state to track future scrolling.
  • this.currentPageState = this.pageStates[ navigationID ] = Object.create( null );
  •  
  • // While we are going to track elements that will be scrolled during the
  • // current page rendering, it is possible that there are elements that were
  • // scrolled during a prior page rendering that still exist on the page, but
  • // were not scrolled recently (such as a secondary router-outlet). As such,
  • // let's look at the previous page state and "pull forward" any state that
  • // still pertains to the current page.
  • if ( ! restoredNavigationID ) {
  •  
  • for ( var selector in previousPageState ) {
  •  
  • var target = this.domUtils.select( selector );
  •  
  • // Only pull the selector forward if it corresponds to an element
  • // that still exists in the rendered page.
  • if ( ! target ) {
  •  
  • continue;
  •  
  • }
  •  
  • // Only pull the selector forward if the target is still at the same
  • // offset after the navigation has taken place. In other words, if
  • // the offset has somehow changed in between the NavigationStart and
  • // NavigationEnd events, then ignore it. To be honest, this really
  • // only applies to the WINDOW, which can change in offset due to the
  • // change in what the Router is actively rendering in the DOM.
  • if ( this.domUtils.getScrollTop( target ) !== previousPageState[ selector ] ) {
  •  
  • continue;
  •  
  • }
  •  
  • this.currentPageState[ selector ] = previousPageState[ selector ];
  •  
  • // For sake of the demo.
  • console.group( "Pulling Scroll Offset Forward from Previous State" );
  • console.log( "selector:", selector );
  • console.log( "offset:", this.currentPageState[ selector ] );
  • console.groupEnd();
  •  
  • }
  •  
  • // If we're restoring a previous page state AND we have that previous page
  • // state cached in-memory, let's copy the previous state and then restore the
  • // offsets in the DOM.
  • } else if ( restoredNavigationID && this.pageStates[ restoredNavigationID ] ) {
  •  
  • // NOTE: We're copying the offsets from the restored state into the
  • // current state instead of just swapping the references because these
  • // navigations are different in the Router history. Since each navigation
  • // - imperative or popstate - gets a unique ID, we never truly "go back"
  • // in history; the Router only "goes forward", with the notion that we're
  • // recreating a previous state sometimes.
  • this.applyPageStateToDom(
  • Object.assign(
  • this.currentPageState,
  • this.pageStates[ restoredNavigationID ]
  • )
  • );
  •  
  • }
  •  
  • // Keep track of the navigation event so we can limit the size of our
  • // in-memory page state cache.
  • this.navigationIDs.push( navigationID );
  •  
  • // Trim the oldest page states as we go so that the in-memory cache doesn't
  • // grow, unbounded.
  • while ( this.navigationIDs.length > 20 ) {
  •  
  • delete( this.pageStates[ this.navigationIDs.shift() as number ] );
  •  
  • }
  •  
  • };
  •  
  • // Filter navigation event streams to the appropriate event handlers.
  • this.router.events.subscribe(
  • ( event: NavigationEvent ) : void => {
  •  
  • if ( event instanceof NavigationStart ) {
  •  
  • handleNavigationStart( event );
  •  
  • } else if ( event instanceof NavigationEnd ) {
  •  
  • handleNavigationEnd();
  •  
  • }
  •  
  • }
  • );
  •  
  • }

I think most of the code here is somewhat self-explanatory (assuming I named things well). But, the part about "pulling offsets forward" is probably not obvious. And, this is where it's important to understand that only part of the page needs to change when you navigate with the Router.

Consider having a Primary and Secondary router-outlet open at the same time. And imagine that both the Primary and Secondary router-outlet represent scrollable elements. Now, consider the following steps:

  1. Open primary and secondary outlet.
  2. Scroll secondary outlet.
  3. Navigate primary outlet.
  4. Navigate primary outlet.

While the secondary-outlet scroll will be recorded in step 3, if you don't scroll the secondary-outlet prior to step 4, there will be no scroll event; and, therefore, no recorded offset. As such, step 4 has to "pull through" the secondary-outlet offset recorded in step 3, assuming that it is still relevant. This way, if the user navigates back to step 4, the polyfill will be able to restore the scroll offset of the secondary-outlet.

And, when the user does return to a previous state (via a popstate navigation), we have to take all of those recorded offsets and apply them back to the DOM (Document Object Model). This is where my polyfill gets very opinionated. Unlike the native scroll restoration behavior, this step is not a point-in-time. Instead, it continues to try and apply offsets to the DOM for a configured amount of time. This way, the application has wiggle-room in how it loads data into the application.

When a page state needs to be restored, I call .applyPageStateToDom():

  • // I attempt to apply the given page-state to the rendered DOM. I will continue to
  • // poll the document until all states have been reinstated; or, until the poll
  • // duration has been exceeded; or, until a subsequent navigation takes place.
  • private applyPageStateToDom( pageState: PageState ) : void {
  •  
  • // For sake of the demo.
  • console.group( "Attempting to Reapply Page State In PopState Navigation" );
  • console.log( JSON.stringify( pageState, null, 4 ) );
  • console.groupEnd();
  •  
  • if ( this.objectIsEmpty( pageState ) ) {
  •  
  • return;
  •  
  • }
  •  
  • // Let's create a copy of the page state so that we can safely delete keys from
  • // it as we successfully apply them to the rendered DOM.
  • var pendingPageState = { ...pageState };
  •  
  • // Setup the scroll retention timer outside of the Angular Zone so that it
  • // doesn't trigger any additional change-detection digests.
  • this.zone.runOutsideAngular(
  • () : void => {
  •  
  • var startedAt = Date.now();
  •  
  • this.applyStateToDomTimer = window.setInterval(
  • () => {
  •  
  • for ( var selector in pendingPageState ) {
  •  
  • var target = this.domUtils.select( selector );
  •  
  • // If the target element doesn't exist in the DOM yet, it
  • // could be an indication of asynchronous loading and
  • // rendering. Move onto the next selector while we still
  • // have time.
  • if ( ! target ) {
  •  
  • continue;
  •  
  • }
  •  
  • // If the element in question has been scrolled (by the user)
  • // while we're attempting to reinstate the previous scroll
  • // offsets, then ignore this state - the user's action should
  • // take precedence.
  • if ( this.scrolledElements.has( target ) ) {
  •  
  • delete( pendingPageState[ selector ] );
  •  
  • // Otherwise, let's try to restore the scroll for the target.
  • } else {
  •  
  • var scrollTop = pendingPageState[ selector ];
  • var resultantScrollTop = this.domUtils.scrollTo( target, scrollTop );
  •  
  • // If the attempt to restore the element to its previous
  • // offset resulted in a match, then stop tracking this
  • // element. Otherwise, we'll continue to try and scroll
  • // it in the subsequent tick.
  • // --
  • // NOTE: We continue to try and update it because the
  • // target element may exist in the DOM but also be
  • // loading asynchronous data that is required for the
  • // previous scroll offset.
  • if ( resultantScrollTop === scrollTop ) {
  •  
  • delete( pendingPageState[ selector ] );
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  • // If there are no more elements to scroll or, we've exceeded our
  • // poll duration, then stop watching the DOM.
  • if (
  • this.objectIsEmpty( pendingPageState ) ||
  • ( ( Date.now() - startedAt ) >= this.pollDuration )
  • ) {
  •  
  • clearTimeout( this.applyStateToDomTimer );
  •  
  • // For sake of the demo.
  • if ( this.objectIsEmpty( pendingPageState ) ) {
  •  
  • console.log( "Successfully reapplied scroll offsets to DOM." );
  •  
  • }
  •  
  • }
  •  
  • },
  • this.pollCadence
  • );
  •  
  • }
  • );
  •  
  • }

As you can see, I setup an interval that tries to restore the previous page state. The cadence and duration of this interval are configured via pollCadence and pollDuration, respectively. These have default values; but, can be overridden when importing the polyfill module in your Angular application.

That's the bulk of the polyfill logic. The rest of the code in that service is just utility code.

The only other thing worth noting is that the polyfill also provides a directive that binds to Angular's router-outlet. It does this in lieu of ever scrolling back to the top of page. As each router-outlet deactivates and then activates, my router-outlet directive records the offset of each ancestor element. When the new view is injected as a sibling to the router-outlet, my directive reinstates the ancestor offsets such that the DOM is left at the offset that it had right before the view was injected.

In other words, my polyfill never scroll to the "top of the page" - it scrolls to the "top of the router-outlet". This is how it "differentiates" between a primary page navigation and something like a Tabbed-view navigation. It's not a perfect approach. But, it's a step in the right direction:

  • // Import the core angular services.
  • import { Directive } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { Event as NavigationEvent } from "@angular/router";
  • import { NavigationEnd } from "@angular/router";
  • import { Router } from "@angular/router";
  • import { RouterOutlet } from "@angular/router";
  • import { Subscription } from "rxjs";
  •  
  • // Import the application components and services.
  • import { DomUtils } from "./dom-utils";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • // I co-opt the <router-outlet> element selector so that I can tap into the life-cycle
  • // of the core RouterOutlet directive.
  • // --
  • // REASON: When the user clicks on a link, it's quite hard to differentiate between a
  • // primary navigation, which should probably scroll the user back to the top of the
  • // viewport; and, something like a tabbed-navigation, which should probably keep the
  • // user's scroll around the offset associated with the tab. As such, we are going to
  • // rely on the inherent scroll-position of the view as the router-outlet target is
  • // pulled out of the DOM.
  • @Directive({
  • selector: "router-outlet"
  • })
  • export class RouterOutletDirective {
  •  
  • private activateEventsSubscription: Subscription | null;
  • private deactivateEventsSubscription: Subscription | null;
  • private domUtils: DomUtils;
  • private elementRef: ElementRef;
  • private offsets: number[];
  • private router: Router;
  • private routerEventsSubscription: Subscription | null;
  • private routerOutlet: RouterOutlet;
  •  
  • // I initialize the router-outlet directive.
  • constructor(
  • domUtils: DomUtils,
  • elementRef: ElementRef,
  • router: Router,
  • routerOutlet: RouterOutlet
  • ) {
  •  
  • this.domUtils = domUtils;
  • this.elementRef = elementRef;
  • this.router = router;
  • this.routerOutlet = routerOutlet;
  •  
  • this.activateEventsSubscription = null;
  • this.deactivateEventsSubscription = null;
  • this.offsets = [];
  • this.routerEventsSubscription = null;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called when the directive is being destroyed.
  • public ngOnDestroy() : void {
  •  
  • ( this.activateEventsSubscription ) && this.activateEventsSubscription.unsubscribe();
  • ( this.deactivateEventsSubscription ) && this.deactivateEventsSubscription.unsubscribe();
  • ( this.routerEventsSubscription ) && this.routerEventsSubscription.unsubscribe();
  •  
  • }
  •  
  •  
  • // I get called once after the directive's inputs have been initialized.
  • public ngOnInit() : void {
  •  
  • // In order to help with natural scroll behavior, we have to listen for the
  • // creation and destruction of router View component.s
  • this.activateEventsSubscription = this.routerOutlet.activateEvents.subscribe(
  • ( event: any ) : void => {
  •  
  • this.handleActivateEvent();
  •  
  • }
  • );
  • this.deactivateEventsSubscription = this.routerOutlet.deactivateEvents.subscribe(
  • ( event: any ) : void => {
  •  
  • this.handleDectivateEvent();
  •  
  • }
  • );
  •  
  • // In order to make sure the offsets don't get applied inappropriately in the
  • // future, we have to listen for navigation events.
  • this.routerEventsSubscription = this.router.events.subscribe(
  • ( event: NavigationEvent ) : void => {
  •  
  • this.handleNavigationEvent( event );
  •  
  • }
  • );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I get called when a new router View component is being rendered.
  • private handleActivateEvent() : void {
  •  
  • if ( ! this.offsets.length ) {
  •  
  • return;
  •  
  • }
  •  
  • console.group( "Ensuring Ancestral Scroll Offsets in New Navigation" );
  • console.log( this.offsets.slice() );
  • console.groupEnd();
  •  
  • // At this point, the View-in-question has been mounted in the DOM (Document
  • // Object Model). We can now walk back up the DOM and make sure that the
  • // previously-recorded offsets (in the last "deactivate" event) are being applied
  • // to the ancestral elements. This will prevent the browser's native desire to
  • // auto-scroll-down a document once the view has been injected. Essentially, this
  • // ensures that we scroll back to the "expected top" as the user clicks through
  • // the application.
  • var node = this.elementRef.nativeElement.parentNode;
  •  
  • while ( node ) {
  •  
  • // If this is an ELEMENT node, set its offset.
  • if ( node.nodeType === 1 ) {
  •  
  • this.domUtils.scrollTo( node, this.offsets.shift() || 0 );
  •  
  • }
  •  
  • node = node.parentNode;
  •  
  • }
  •  
  • // At the top, we'll always set the window's scroll.
  • this.domUtils.scrollTo( window, this.offsets.shift() || 0 );
  •  
  • }
  •  
  •  
  • // I get called when an existing router View component is being unmounted.
  • private handleDectivateEvent() : void {
  •  
  • // At this point, the View-in-question has already been removed from the
  • // document. Let's walk up the DOM (Document Object Model) and record the scroll
  • // position of all scrollable elements. This will give us a sense of what the DOM
  • // should look like after the next View is injected.
  • var node = this.elementRef.nativeElement.parentNode;
  •  
  • while ( node ) {
  •  
  • // If this is an ELEMENT node, capture its offset.
  • if ( node.nodeType === 1 ) {
  •  
  • this.offsets.push( this.domUtils.getScrollTop( node ) );
  •  
  • }
  •  
  • node = node.parentNode;
  •  
  • }
  •  
  • // At the top, we'll always record the window's scroll.
  • this.offsets.push( this.domUtils.getScrollTop( window ) );
  •  
  • }
  •  
  •  
  • // I get called whenever a router event is raised.
  • private handleNavigationEvent( event: NavigationEvent ) : void {
  •  
  • // The "offsets" are only meant to be used across a single navigation. As such,
  • // let's clear out the offsets at the end of each navigation in order to ensure
  • // that old offsets don't accidentally get applied to a future view mounted by
  • // the current router-outlet.
  • if ( event instanceof NavigationEnd ) {
  •  
  • this.offsets = [];
  •  
  • }
  •  
  • }
  •  
  • }

Now, when we run this code, and navigate backwards and forward through the application, we can see the scroll offsets being recorded and restored:


 
 
 

 
 Scroll retention polyfill for Angular 7.0.4 stores and resets scroll offsets as the user navigates around the application. 
 
 
 

As you can see, it records and restores the scroll offsets for the secondary router-outlets as well as the primary page. In fact, it records the scroll offsets for any scrollable element type that isn't explicitly blacklisted in the DomUtils.

ASIDE: Right now, the CSS selector for the scrolled element is calculated by walking up the DOM tree and assumes that simulated CSS encapsulation attributes are in place. In the future, I think I could configure the DomUtils to look for explicit attributes or IDs.

To get a sense of how the native Angular behavior and my scroll-retention polyfill diverge, I've built this demo both with and without the polyfill. This will allow you to see how differently the two applications behave:

There's no doubt that recording and restoring scroll offsets is a sticky matter. My polyfill isn't a full solution; but, it is a step in a powerful direction. Especially considering the fact that the Angular application basically knows nothing about it - it's just a drop-in module. If nothing else, hopefully this was just some interesting code to noodle on.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.