Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Haley Groves
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Haley Groves

Restoring And Resetting The Scroll Position On Navigation With A Polyfill In Angular 5.2.3

By Ben Nadel on

In a "normal" web application, with a full round-trip request-response life-cycle, restoring and resetting the scroll position of the page is simple: the browser's native functionality just handles it for you. But, in a Single-Page Application (SPA), were pages are rendered with DOM (Document Object Model) manipulation and populated with AJAX (Asynchronous JavaScript and JSON) payloads, managing scroll position is anything but trivial. Throw in the ability for Angular to render multiple router outlets simultaneously, and the problem gets even more complicated. Over the past few weeks, I've been trying to come up with a "polyfill" module for Angular 5.2.3 that seamlessly handles scroll restoration and resetting for your. And while my solution doesn't work all the time - and the nuances of the browser's "pop state" history API continue to confuse me - I think what I have comes pretty close to being "helpful."

CAUTION: Use this as your own risk - I haven't tested it in a production-grade application.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

My goal for this Angular "polyfill" module was several fold:

  1. I wanted to automatically reinstate scroll positions when a user moved Back and Forward through their browser history (using PopState events).
  2. I wanted to automatically reset the scroll (ie, scroll to the top) of elements as the user navigated through the application using routerLinks.
  3. I wanted the first two features to be implemented as seamlessly as possible.
  4. Your Angular application uses "simulated encapsulation" for its CSS; and, the resultant ngHost and ngContent attributes can be used to calculate an "accurate enough" CSS selector for a given element.

NOTE: I have a pervious polyfill that handles "anchor links" in Angular.

Each of these goals is handled by a different portion of the polyfill module. But, the entirety of the logic is handled within the boundary of the polyfill. Meaning, all you have to do is import the RetainScrollPolyfillModule into your application module (and shared directives module) and you're good to go; no logic needs to be added to your existing components or directives.

My polyfill module makes two very strong assumptions:

  1. Your Angular application is using the history.pushState() API to implement navigation (though, portions of the polyfill will bail-out if it sees that pushState isn't supported).
  2. It's safe to monkey-patch the .pushState() method in order to inject a "state ID" in each history entry. At the time of this writing, Angular always passes a "null" state through to the pushState() method. Of course, if this were to change, then the polyfill would have to be removed. But, of course, that's the goal of a polyfill - to eventually be removed.

To develop and test this scroll retention module, I created a simple Angular application that has one primary router-outlet and two secondary router-outlets. Each of the router-outlets can load a list of data using an asynchronous load action that includes simulated network latency. The goal here is to setup a diverse variety of router-outlet states that could be consumed during various navigation events.

I won't look at the components themselves, as they are not all that relevant to the polyfill; but, we should look at the application module to see how the polyfill is included:

  • // Import the core angular services.
  • import { BrowserModule } from "@angular/platform-browser";
  • import { NgModule } from "@angular/core";
  • import { RouterModule } from "@angular/router";
  • import { Routes } from "@angular/router";
  •  
  • // Import the application components and services.
  • import { AppComponent } from "./app.component";
  • import { RetainScrollPolyfillModule } from "./retain-scroll-polyfill/retain-scroll-polyfill.module";
  • import { SecondaryDetailViewComponent } from "./views/secondary-detail-view.component";
  • import { SecondaryListViewComponent } from "./views/secondary-list-view.component";
  • import { SecondaryViewComponent } from "./views/secondary-view.component";
  • import { SectionAViewComponent } from "./views/section-a-view.component";
  • import { SectionBViewComponent } from "./views/section-b-view.component";
  • import { SectionCTab1ViewComponent } from "./views/section-c-tab-1-view.component";
  • import { SectionCTab2ViewComponent } from "./views/section-c-tab-2-view.component";
  • import { SectionCViewComponent } from "./views/section-c-view.component";
  • import { TertiaryDetailViewComponent } from "./views/tertiary-detail-view.component";
  • import { TertiaryListViewComponent } from "./views/tertiary-list-view.component";
  • import { TertiaryViewComponent } from "./views/tertiary-view.component";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • var routes: Routes = [
  • {
  • path: "app",
  • children: [
  • {
  • path: "main",
  • children: [
  • {
  • path: "section-a",
  • component: SectionAViewComponent
  • },
  • {
  • path: "section-b",
  • component: SectionBViewComponent
  • },
  • {
  • path: "section-c",
  • component: SectionCViewComponent,
  • children: [
  • {
  • path: "tab-1",
  • component: SectionCTab1ViewComponent
  • },
  • {
  • path: "tab-2",
  • component: SectionCTab2ViewComponent
  • }
  • ]
  • }
  • ]
  • },
  • {
  • outlet: "secondary",
  • path: "secondary",
  • component: SecondaryViewComponent,
  • children: [
  • {
  • path: "",
  • pathMatch: "full",
  • component: SecondaryListViewComponent
  • },
  • {
  • path: "detail",
  • component: SecondaryDetailViewComponent
  • }
  • ]
  • },
  • {
  • outlet: "tertiary",
  • path: "tertiary",
  • component: TertiaryViewComponent,
  • children: [
  • {
  • path: "",
  • pathMatch: "full",
  • component: TertiaryListViewComponent
  • },
  • {
  • path: "detail",
  • component: TertiaryDetailViewComponent
  • }
  • ]
  • }
  • ]
  • },
  •  
  • // Redirect from the root to the "/app" prefix (this makes other features, like
  • // secondary outlets) easier to implement later on.
  • {
  • path: "",
  • pathMatch: "full",
  • redirectTo: "app"
  • }
  • ];
  •  
  • @NgModule({
  • bootstrap: [
  • AppComponent
  • ],
  • imports: [
  • BrowserModule,
  • RetainScrollPolyfillModule.forRoot({
  • // Tell the polyfill how long to poll the document after a route change in
  • // order to look for elements that need to be restored to a previous offset.
  • pollDuration: 3000,
  • pollCadence: 50
  • }),
  • RouterModule.forRoot(
  • routes,
  • {
  • // Tell the router to use the HashLocationStrategy.
  • useHash: true,
  • enableTracing: false
  • }
  • )
  • ],
  • declarations: [
  • AppComponent,
  • SecondaryDetailViewComponent,
  • SecondaryListViewComponent,
  • SecondaryViewComponent,
  • SectionAViewComponent,
  • SectionBViewComponent,
  • SectionCTab1ViewComponent,
  • SectionCTab2ViewComponent,
  • SectionCViewComponent,
  • TertiaryDetailViewComponent,
  • TertiaryListViewComponent,
  • TertiaryViewComponent
  • ],
  • providers: [
  • // CAUTION: We don't need to specify the LocationStrategy because we are setting
  • // the "useHash" property in the Router module above.
  • // --
  • // {
  • // provide: LocationStrategy,
  • // useClass: HashLocationStrategy
  • // }
  • ]
  • })
  • export class AppModule {
  • // ...
  • }

Most of this is just the noise of setting up all the routes and component declarations. Really, the only important part of this file is this portion:

  • RetainScrollPolyfillModule.forRoot({
  • // Tell the polyfill how long to poll the document after a route change in
  • // order to look for elements that need to be restored to a previous offset.
  • pollDuration: 3000,
  • pollCadence: 50
  • })

Here, we're importing the RetainScrollPolyfillModule with some configuration options. At this time, the polyfill module only has options about how long (and at what interval) the DOM (Document Object Model) should be polled when trying to reinstate scroll positions when using popState (ie, going back through the browser history). By default, each navigation event will poll the DOM for up to 3-seconds while waiting for the Views to reach the expected state. This allows asynchronous data-loading to change the size of the View after the navigation event has completed.

In this demo, I only include the RetainScrollPolyfillModule once. But, it provides both a Service and a Directive. That means that if you have an application that uses router-outlet in sub-modules, you'll also have to include the RetainScrollPolyfillModule in those sub-modules (in order to gain access to the directive). Since many applications have a "shared module", however, you can just import the non-root version of RetainScrollPolyfillModule into that shared module and you should be good to go.

Here is the RetainScrollPolyfillModule. As you can see, there is one directive declaration and several internal-use services.

  • // Import the core angular services.
  • import { ModuleWithProviders } from "@angular/core";
  • import { NgModule } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { DomUtils } from "./dom-utils";
  • import { Options as ServiceOptions } from "./retain-scroll-polyfill.service";
  • import { OPTIONS_TOKEN as ServiceOptionsToken } from "./retain-scroll-polyfill.service";
  • import { RetainScrollPolyfillService } from "./retain-scroll-polyfill.service";
  • import { RouterOutletDirective } from "./router-outlet.directive";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface ModuleOptions {
  • pollDuration?: number;
  • pollCadence?: number;
  • }
  •  
  • @NgModule({
  • exports: [
  • RouterOutletDirective
  • ],
  • declarations: [
  • RouterOutletDirective
  • ]
  • })
  • export class RetainScrollPolyfillModule {
  •  
  • // I setup the module after it has been initialized.
  • // --
  • // NOTE: This is essentially a "run block" for the module. We need to use this run
  • // block in order to ensure that the polyfill service is actually created and bound
  • // to the UI.
  • constructor( polyfillService: RetainScrollPolyfillService ) {
  •  
  • console.group( "Retain Scroll Polyfill Module" );
  • console.warn( "This module assumes push-state-based navigation." );
  • console.warn( "This module monkey-patches the .pushState() history method." );
  • console.warn( "This module assumes simulated encapsulation attributes for CSS selector generation." );
  • console.groupEnd();
  •  
  • }
  •  
  • // ---
  • // STATIC METHODS.
  • // ---
  •  
  • // I setup the module providers for the application.
  • static forRoot( options: ModuleOptions = {} ) : ModuleWithProviders {
  •  
  • return({
  • ngModule: RetainScrollPolyfillModule,
  • providers: [
  • DomUtils,
  • RetainScrollPolyfillService,
  • {
  • provide: ServiceOptionsToken,
  • useValue: {
  • pollDuration: ( options.pollDuration || 3000 ),
  • pollCadence: ( options.pollCadence || 50 )
  • }
  • }
  • ]
  • });
  •  
  • }
  •  
  • }

NOTE: You could theoretically override the DomUtils service if you wanted to change which element types were supported; or, how to calculate CSS selectors from a given element reference.

The main service - RetainScrollPolyfillService - handles the storing and retaining of scroll offsets / positions as the user navigates backwards and forward through the application. It does this by attaching a global "scroll" event, monkey-patching the history API and, associating scroll values with each history state. Then, as the user navigates the application with PopState events, the service looks for a cached set of scroll values and attempts to apply them to the DOM.

This service is non-trivial. I had to do a lot of wrestling with the browser's default behavior for scroll restoration. And, I had to deal with the fact that the PopState event doesn't always "work" the way I expected it to. Ultimately, however, I end up storing a hash-map of URLs that maps to "PageState" object. Each PageState contains a collection of "ElementState" objects, which combine a CSS selector with a numeric scroll position.

  • // Import the core angular services.
  • import { Inject } from "@angular/core";
  • import { Injectable } from "@angular/core";
  • import { InjectionToken } from "@angular/core";
  • import { Event as NavigationEvent } from "@angular/router";
  • import { NavigationEnd } from "@angular/router";
  • import { NavigationStart } from "@angular/router";
  • import { NgZone } from "@angular/core";
  • import { Router } from "@angular/router";
  •  
  • // Import the application components and services.
  • import { DomUtils } from "./dom-utils";
  • import { Target } from "./dom-utils";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • /**
  • * The algorithm in this polyfill works based on the order-of-operations of different
  • * kinds of navigation events. When a navigation is initiated by the application itself,
  • * such as with a [routerLink] click or a .navigate() method call, the operations are as
  • * follows:
  • *
  • * - NavigationStart
  • * - PushState <--- ( monkey-patched by this polyfill )
  • * - NavigationEnd
  • *
  • * And, if the navigation is initiated by the browser, such as through the Back button or
  • * a direct change to the URL, the operations are as follows:
  • *
  • * - PopState
  • * - NavigationStart
  • * - NavigationEnd
  • *
  • * As such, we can know which kind of navigation is happening (PushState vs. PopState) by
  • * the time we get to the NavigationStart event handler (PopState can be flagged).
  • *
  • * This algorithm monkey-patches the history.pushState() method in order to TRY and keep
  • * track of a state / history ID that can be mapped back to a render state. This helps
  • * reinstate views after the Back and Forward buttons have been pressed. But, it doesn't
  • * seem to work all that well. Though, it's possible that's just my relatively shallow
  • * understanding of how PopState works.
  • *
  • , CAUTION: We are monkey-patching the history.pushState() method and assuming that it is
  • * ALWAYS RECEIVING NULL FROM THE ANGULAR APPLICATION. This is likely to change in the
  • * future, which means this is a fairly brittle polyfill.
  • */
  •  
  • export var OPTIONS_TOKEN = new InjectionToken<Options>( "RetainScrollPolyfillService.Options" );
  •  
  • export interface Options {
  • pollDuration: number;
  • pollCadence: number;
  • }
  •  
  • interface RenderStates {
  • [ key: string ]: Page;
  • }
  •  
  • interface Page {
  • url: string;
  • pageStates: PageState[];
  • }
  •  
  • interface PageState {
  • historyID: string;
  • elementStates: ElementStates;
  • }
  •  
  • interface ElementStates {
  • [ key: string ]: ElementState;
  • }
  •  
  • interface ElementState {
  • selector: string;
  • scrollTop: number;
  • }
  •  
  • @Injectable()
  • export class RetainScrollPolyfillService {
  •  
  • private applyStateToDomTimer: number;
  • private currentHistoryID: string;
  • private domUtils: DomUtils;
  • private historyCounter: number;
  • private lastNavigationStartAt: number;
  • private pendingElements: Set<Target>;
  • private pendingElementsTimer: number;
  • private pollCadence: number;
  • private pollDuration: number;
  • private poppedHistoryID: string;
  • private previousPageState: PageState;
  • private renderStates: RenderStates;
  • private router: Router;
  • private scrolledElements: Map<Target, number>;
  • private zone: NgZone;
  •  
  • // I initialize the polyfill service.
  • constructor(
  • domUtils: DomUtils,
  • router: Router,
  • zone: NgZone,
  •  
  • @Inject( OPTIONS_TOKEN ) options: Options
  • ) {
  •  
  • this.domUtils = domUtils;
  • this.router = router;
  • this.zone = zone;
  •  
  • // This algorithm works by monkey-patching the .pushState() method. So, if
  • // pushState isn't supported, then there's really no reason to proceed with this
  • // portion of the polyfill (the router-outlet co-opting can still operate).
  • if ( ! this.supportsPushState() ) {
  •  
  • return;
  •  
  • }
  •  
  • this.applyStateToDomTimer = 0;
  • this.historyCounter = 0;
  • this.lastNavigationStartAt = 0;
  • this.pendingElements = new Set();
  • this.pendingElementsTimer = 0;
  • this.pollCadence = options.pollCadence;
  • this.pollDuration = options.pollDuration;
  • this.poppedHistoryID = null;
  • this.previousPageState = null;
  • this.renderStates = Object.create( null );
  • this.scrolledElements = new Map();
  •  
  • this.currentHistoryID = this.getNextHistoryID();
  •  
  • this.setupPushStateMonkeyPatch();
  • this.setupScrollBinding();
  • this.setupPopstateBinding();
  • this.setupRouterBinding();
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I attempt to apply the given page-state to the active 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 has taken place.
  • private applyPageStateToDom( pageState: PageState ) : void {
  •  
  • // The element state are stored as object keys based on selectors. In order to
  • // make this set easier to deal with, let's convert the hash to an array.
  • var elementStates = Object.keys( pageState.elementStates ).map(
  • ( selector: string ) : ElementState => {
  •  
  • return( pageState.elementStates[ selector ] );
  •  
  • }
  • );
  •  
  • if ( ! elementStates.length ) {
  •  
  • return;
  •  
  • }
  •  
  • console.group( "Attempting to Reapply Page State In PopState Navigation" );
  • console.log( JSON.stringify( elementStates, null, 4 ) );
  • console.groupEnd();
  •  
  • // 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 = setInterval(
  • () => {
  •  
  • // NOTE: We're looping backwards over this collection so that we
  • // can safely .splice() states out of it, mid-iteration, if the
  • // state has been successfully applied.
  • for ( var i = ( elementStates.length - 1 ) ; i >= 0 ; i-- ) {
  •  
  • var elementState = elementStates[ i ];
  • var target = this.domUtils.select( elementState.selector );
  •  
  • if ( target ) {
  •  
  • // 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 ) ) {
  •  
  • elementStates.splice( i, 1 );
  •  
  • } else {
  •  
  • var resultantScrollTop = this.domUtils.scrollTo( target, elementState.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 be loading asynchronous data
  • // that is required for the previous scroll offset.
  • if ( resultantScrollTop === elementState.scrollTop ) {
  •  
  • elementStates.splice( i, 1 );
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  • }
  •  
  • // If there are no more elements to scroll; or, we've exceeded
  • // our poll duration, then stop watching the DOM.
  • if ( ! elementStates.length || ( ( Date.now() - startedAt ) >= this.pollDuration ) ) {
  •  
  • clearTimeout( this.applyStateToDomTimer );
  •  
  • }
  •  
  • },
  • this.pollCadence
  • );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I commit the pending elements to the scrolled elements collection.
  • private commitPendingElements() : void {
  •  
  • this.pendingElements.forEach(
  • ( target: Target ) => {
  •  
  • this.scrolledElements.set( target, this.domUtils.getScrollTop( target ) );
  •  
  • }
  • );
  •  
  • this.pendingElements.clear();
  •  
  • }
  •  
  •  
  • // I get the page-state associated with the given history ID. Or, if it doesn't
  • // exist, I created it, add it to the render-state, and return it.
  • private ensurePageState( historyID: string, useMostRecentAsDefault: boolean = false ) : PageState {
  •  
  • var renderedUrl = this.router.url;
  •  
  • // Ensure that the current URL is being tracked by the render-state.
  • if ( ! this.renderStates[ renderedUrl ] ) {
  •  
  • this.renderStates[ renderedUrl ] = {
  • url: renderedUrl,
  • pageStates: []
  • };
  •  
  • }
  •  
  • var pageStates = this.renderStates[ renderedUrl ].pageStates;
  •  
  • // If we already have a page-state associated with the given ID, return it.
  • // --
  • // NOTE: We're starting at the front of the collection since the newest items
  • // are being unshifted onto the collection (ie, the most recent page states are
  • // at the start of the collection). This is where the user is most likely to be
  • // performing navigations.
  • for ( var pageState of pageStates ) {
  •  
  • if ( pageState.historyID === historyID ) {
  •  
  • return( pageState );
  •  
  • }
  •  
  • }
  •  
  • // If we've made it this far, there is no page-state associated with the given
  • // ID. As such, we'll need to create one.
  • var pageState: PageState = {
  • historyID: historyID,
  • elementStates: Object.create( null )
  • };
  •  
  • // Under certain circumstances, when we're creating a new page-state, we want to
  • // use the most recent page-state (at the same URL) as the basis for the new
  • // page-state. This would make sense if we popped the history and did not receive
  • // a known history ID. In that case, we would want to model the page on a best
  • // guess of what the page may have looked like. To be clear, this is a janky step
  • // trying to make up for a janky history behavior.
  • if ( useMostRecentAsDefault && pageStates.length ) {
  •  
  • console.warn( "No PageState associated with popState - using recent values as fallback." );
  • Object.assign( pageState.elementStates, pageStates[ 0 ].elementStates );
  •  
  • }
  •  
  • pageStates.unshift( pageState );
  •  
  • // Theoretically, the stored page states will grown in an unbounded fashion if
  • // the application is kept open indefinitely; so, let's just keep each page under
  • // a length limit.
  • if ( pageStates.length > 15 ) {
  •  
  • pageStates.pop();
  •  
  • }
  •  
  • return( pageState );
  •  
  • }
  •  
  •  
  • // I get the element-states from the given set of nodes.
  • private getElementStatesFromNodes( nodes: Map<Target, number> ) : ElementStates {
  •  
  • var elementStates: ElementStates = Object.create( null );
  •  
  • nodes.forEach(
  • ( scrollTop: number, target: Target ) => {
  •  
  • var selector = this.domUtils.getSelector( target );
  •  
  • elementStates[ selector ] = { selector, scrollTop };
  •  
  • }
  • );
  •  
  • return( elementStates );
  •  
  • }
  •  
  •  
  • // I generate the next unique history state ID.
  • private getNextHistoryID() : string {
  •  
  • return( `retain-scroll-${ ++this.historyCounter }-${ Date.now() }` );
  •  
  • }
  •  
  •  
  • // I bind to the popstate event, which is triggered whenever the browser initiates
  • // a change in the view state (such as through the Back or Forward buttons).
  • private setupPopstateBinding() : void {
  •  
  • // Setup the popstate binding outside of the Angular Zone so it doesn't trigger
  • // any additional change-detection digests.
  • this.zone.runOutsideAngular(
  • () : void => {
  •  
  • window.addEventListener(
  • "popstate",
  • ( event: PopStateEvent ) : void => {
  •  
  • // CAUTION: The history object seems to be somewhat janky for me
  • // (or, maybe I'm just not smart enough to figure it out). That
  • // said, it seems that using a combination of Back and Forwards
  • // operations quickly creates a scenario in which the history
  • // object stops reporting the correct (any) state object. As
  • // such, there are many times in which a popstate will not result
  • // in an accessible "history ID", even though we've monkey-patched
  • // the .pushState() method. In such cases, we'll just use a newly-
  • // generated ID, which will cause a new state object to be created
  • // by the navigation handler.
  • // --
  • // NOTE: We are storing the "popped" ID as a separate value from
  • // the "current" ID so that we have time to save the current state
  • // of the DOM (associated with the "current" ID) before the
  • // navigation starts.
  • try {
  •  
  • this.poppedHistoryID = event.state.id;
  •  
  • } catch ( error ) {
  •  
  • this.poppedHistoryID = this.getNextHistoryID();
  •  
  • }
  •  
  • }
  • );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I override the native .pushState() method, ensuring that an unique ID is
  • // associated with each view state.
  • // --
  • // CAUTION: This assumes that Angular never provides a non-null "state" which, at
  • // the time of this writing, appears to be true. However, it is a dicey assumption
  • // that is likely to change in the future.
  • private setupPushStateMonkeyPatch() : void {
  •  
  • var corePushState = window.history.pushState;
  •  
  • // Monkey-patch pushState() outside of the Angular Zone so it doesn't trigger any
  • // additional change-detection digests.
  • this.zone.runOutsideAngular(
  • () : void => {
  •  
  • window.history.pushState = ( state: any, title: string, url: string ) : void => {
  •  
  • console.warn( "Intercepting .pushState()" );
  • // The unique ID pushed into each state will become associated with
  • // any changes made the document's scroll offsets before the next
  • // navigation is initiated.
  • corePushState.call(
  • window.history,
  • {
  • id: ( this.currentHistoryID = this.getNextHistoryID() ),
  • originalState: state
  • },
  • title,
  • url
  • );
  •  
  • };
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I bind to the router events and perform to primary actions:
  • //
  • // - Save the current page-state whenever navigating away from the current view.
  • // - Reinstate an old page-state whenever navigating to an old view.
  • private setupRouterBinding() : void {
  •  
  • this.router.events.subscribe(
  • ( event: NavigationEvent ) : void => {
  •  
  • // 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.
  • if ( event instanceof NavigationStart ) {
  •  
  • this.lastNavigationStartAt = Date.now();
  •  
  • // If the user is navigating away from the current view, kill any
  • // timers that may be trying to reinstate a page-state or keep track
  • // of any pending scrolling.
  • clearTimeout( this.applyStateToDomTimer );
  • clearTimeout( this.pendingElementsTimer );
  • this.pendingElements.clear();
  •  
  • var currentPageState = this.ensurePageState( this.currentHistoryID );
  •  
  • // If any elements have been scrolled while the view was rendered,
  • // add them to the current page-state.
  • if ( this.scrolledElements.size ) {
  •  
  • Object.assign(
  • currentPageState.elementStates,
  • this.getElementStatesFromNodes( this.scrolledElements )
  • );
  •  
  • this.scrolledElements.clear();
  •  
  • }
  •  
  • // While we track elements that have been scrolled during the current
  • // page rendering, it is likely that there are elements that were
  • // scrolled during a prior page rendering (and still have a non-zero
  • // scroll offset, such a secondary router outlet). We want to
  • // propagate those values with the current page state so that a use
  • // of the Back button (for example) will reinstate those elements in
  • // addition to the ones directly affected during the current page
  • // rendering.
  • // --
  • // NOTE: We only want to do this as the user moves forward in time;
  • // not if the user is jumping to a previous point in history.
  • if ( this.previousPageState && ! this.poppedHistoryID ) {
  •  
  • for ( var selector in this.previousPageState.elementStates ) {
  •  
  • // We only care about selectors that are missing from the
  • // current page-state. If the selector exists, it means that
  • // the current page-state has the more up-to-date element
  • // state.
  • if ( currentPageState.elementStates[ selector ] ) {
  •  
  • continue;
  •  
  • }
  •  
  • var target = this.domUtils.select( selector )
  •  
  • // We only care about the selectors that match elements that
  • // are still rendered on the page. A non-rendered element
  • // won't be relevant for a future popstate navigation.
  • if ( ! target ) {
  •  
  • continue;
  •  
  • }
  •  
  • // We only care about targeted elements that are still at the
  • // same scroll offset as the previous state. If the offsets
  • // don't match, then it's likely that the currently rendered
  • // page is not compatible with the previous state. This can
  • // happen if you navigate through a page that doesn't have
  • // sufficient content to create scrolling (usually on the
  • // window object).
  • if ( this.domUtils.getScrollTop( target ) !== this.previousPageState.elementStates[ selector ].scrollTop ) {
  •  
  • continue;
  •  
  • }
  •  
  • console.group( "Pulling Scroll Offset Forward from Previous State" );
  • console.log( selector );
  • console.log( this.previousPageState.elementStates[ selector ].scrollTop );
  • console.groupEnd();
  •  
  • currentPageState.elementStates[ selector ] = {
  • selector: selector,
  • scrollTop: this.previousPageState.elementStates[ selector ].scrollTop
  • };
  •  
  • }
  •  
  • }
  •  
  • this.previousPageState = currentPageState;
  •  
  • // The goal of the NavigationEnd event is to reinstate a page-state in
  • // the event that the page is being rendered as the result of a popstate
  • // event (ex, the user hit the Back or Forward buttons).
  • } else if ( event instanceof NavigationEnd ) {
  •  
  • if ( this.poppedHistoryID ) {
  •  
  • this.currentHistoryID = this.poppedHistoryID;
  • this.poppedHistoryID = null;
  •  
  • // Get the old page-state associated with the popped history ID.
  • // --
  • // NOTE: This will create a page-state if none has yet been
  • // associated with the given ID.
  • var currentPageState = this.ensurePageState( this.currentHistoryID, true );
  •  
  • this.applyPageStateToDom( currentPageState );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // 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 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).
  • var scrollBufferWindow = 100;
  •  
  • window.addEventListener(
  • "scroll",
  • ( event: Event ) : void => {
  •  
  • // If the scroll event happens immediately following a
  • // navigation, then ignore it - it is likely a scroll that was
  • // forced by the browser's native behavior.
  • if ( ( Date.now() - this.lastNavigationStartAt ) < scrollBufferWindow ) {
  •  
  • return;
  •  
  • }
  •  
  • var target = this.domUtils.getTargetFromScrollEvent( event );
  •  
  • // If the scrolled element is one of the elements that we want to
  • // keep track of (it will be null otherwise), let's put it in a
  • // pending elements set. This way, we can debounce the reading of
  • // the scroll offset.
  • if ( target ) {
  •  
  • this.pendingElements.add( target );
  •  
  • // CAUTION: We are actively trying to inspect the scroll
  • // offset while the user is interacting with the page, as
  • // opposed to just inspecting the element at the start of
  • // the next navigation, because the browser's native
  • // behaviors make this hard to do. By eagerly storing the
  • // scroll offset, we don't have to worry about the complex
  • // and confusing interaction of the page state, browser
  • // behavior, and navigation events.
  • clearTimeout( this.pendingElementsTimer );
  • this.pendingElementsTimer = setTimeout(
  • () => {
  •  
  • this.commitPendingElements();
  •  
  • },
  • scrollBufferWindow
  • );
  •  
  • }
  •  
  • },
  • // 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
  • );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I determine if the current browser supports pushState.
  • private supportsPushState() : boolean {
  •  
  • return( !! ( window && window.history && window.history.pushState ) );
  •  
  • }
  •  
  • }

Like I said, a non-trivial amount of code and logic went into trying to figure out how to retain scroll position. I tried to leave a ton of comments that explain to you (and to myself, frankly) what I was thinking in the various parts of the control flow.

The other part of the polyfill module is a directive that co-opts the "router-outlet" element. The router-outlet.directive.ts binds to the <router-outlet> element so that it can listen for component activation and deactivation events. This directive is used to scroll portions of the page "back to the top" as the user navigates using the RouterLink controls.

It works, essentially, but looking at the scroll offsets when a component is deactivated (and removed from the DOM); and then, making sure those same offsets are set when the next component is activated in the DOM.

  • // 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/Subscription";
  •  
  • // 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.
  • @Directive({
  • selector: "router-outlet"
  • })
  • export class RouterOutletDirective {
  •  
  • private activateEventsSubscription: Subscription;
  • private deactivateEventsSubscription: Subscription;
  • private domUtils: DomUtils;
  • private elementRef: ElementRef;
  • private offsets: number[];
  • private router: Router;
  • private routerEventsSubscription: Subscription;
  • 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() );
  •  
  • }
  •  
  • node = node.parentNode;
  •  
  • }
  •  
  • // At the top, we'll always set the window's scroll.
  • this.domUtils.scrollTo( window, this.offsets.shift() );
  •  
  • }
  •  
  •  
  • // 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.splice( 0, this.offsets.length );
  •  
  • }
  •  
  • }
  •  
  • }

And, finally, the DomUtils library that creates a thin layer of abstraction around the scrolling and CSS selector generation:

  • var WINDOW_SELECTOR = "__window__";
  • var NG_ENCAPSULATION_PATTERN = /^_ng(host|content)\b/i;
  •  
  • export type Target = Window | Element;
  •  
  • // I provide a unified interface for dealing with scroll offsets across different types
  • // of targets (elements vs. windows).
  • export class DomUtils {
  •  
  • // I determine if the target at the given selector exists in the active DOM.
  • public exists( selector: string ) : boolean {
  •  
  • return( !! this.select( selector ) );
  •  
  • }
  •  
  •  
  • // I get the scroll-top of the given target in the active DOM.
  • public getScrollTop( target: Target ) : number {
  •  
  • if ( target instanceof Window ) {
  •  
  • return( window.scrollY );
  •  
  • } else {
  •  
  • return( target.scrollTop );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I return the CSS selector for the given target.
  • // --
  • // NOTE: The generated selector is intended to be consumed by this class only -
  • // it may not produce a valid CSS selector.
  • public getSelector( target: Target ) : string {
  •  
  • // NOTE: I am breaking this apart because TypeScript was having trouble dealing
  • // with type-guard. I believe this is part of this bug:
  • // --
  • // https://github.com/Microsoft/TypeScript/issues/7271#issuecomment-360123191
  • if ( target instanceof Window ) {
  •  
  • return( WINDOW_SELECTOR );
  •  
  • } else {
  •  
  • return( this.getSelectorForElement( target ) );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I get the scrollable target for the given "scroll" event.
  • // --
  • // NOTE: If you want to ignore (ie, not reinstate the scroll) of a particular type
  • // of DOM element, return NULL from this method.
  • public getTargetFromScrollEvent( event: Event ) : Target | null {
  •  
  • var node = event.target;
  •  
  • if ( node instanceof HTMLDocument ) {
  •  
  • return( window );
  •  
  • } else if ( node instanceof Element ) {
  •  
  • return( node );
  •  
  • }
  •  
  • return( null );
  •  
  • }
  •  
  •  
  • // I attempt to scroll the given target to the given scrollTop and return the
  • // resultant value presented by the target.
  • public scrollTo( target: Target, scrollTop: number ) : number {
  •  
  • if ( target instanceof Window ) {
  •  
  • target.scrollTo( 0, scrollTop );
  •  
  • return( target.scrollY );
  •  
  • } else if ( target instanceof Element ) {
  •  
  • target.scrollTop = scrollTop;
  •  
  • return( target.scrollTop );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I return the target accessible at the given CSS selector.
  • public select( selector: string ) : Target | null {
  •  
  • if ( selector === WINDOW_SELECTOR ) {
  •  
  • return( window );
  •  
  • } else {
  •  
  • return( document.querySelector( selector ) );
  •  
  • }
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I generate a CSS selector for the given target.
  • private getSelectorForElement( target: Element ) : string {
  •  
  • var selectors: string[] = [];
  •  
  • var current = <Node>target;
  •  
  • while ( current && ( current.nodeName !== "BODY" ) ) {
  •  
  • var selector = current.nodeName.toLowerCase();
  •  
  • for ( var attribute of Array.from( current.attributes ) ) {
  •  
  • if ( attribute.name.search( NG_ENCAPSULATION_PATTERN ) === 0 ) {
  •  
  • selector += `[${ attribute.name }]`;
  •  
  • }
  •  
  • }
  •  
  • selectors.unshift( selector );
  •  
  • current = current.parentNode;
  •  
  • }
  •  
  • return( selectors.join( " > " ) );
  •  
  • }
  •  
  •  
  • // I check to see if the given node is the root scrollable node - meaning, the node
  • // that is associated with the BODY scroll event.
  • private isRootScrollableNode( node: Node ) : boolean {
  •  
  • return( node instanceof HTMLDocument );
  •  
  • }
  •  
  • }

The most questionable part of this service is the way in which it generates a CSS selector from a given element reference. Essentially, it walks up the DOM tree, starting at the given reference, and then creates a descendent selector rule based on the tagName and simulated encapsulation attributes. I am sure this is not the best approach; but, it was the easiest approach.

To get a sense of how this all fits together, you should just watch the video. But, I've also included a lot of console-logging so that you can see when and how the polyfill module is trying to reinstate the scroll positions:


 
 
 

 
 Retain scroll position polyfill for Angular 5.2.3. 
 
 
 

This was a bear of a problem to try and wrap my head around. Managing scroll positions and retention in an Angular single-page application (SPA) is a very non-trivial task. At least, for my unfrozen caveman lawyer brain. If nothing else, though, perhaps this can inspire others to come up with more simple and / or more robust solutions. And hopefully, the Angular core code will just handle this for us!



Looking For A New Job?

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

Reader Comments

This looks super interesting. For some reason I had thought that the router hierarchy (parent-child relationship) allowed for components to persist state as long as you were popping from a child to a parent route. I guess this is not the case.. Any plans to release this on Github or put it in front of the Angular team for feedback?

Reply to this Comment

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.