avatarw3ird0h

Summary

The provided content discusses a reactive solution using RxJS for restoring scroll positions in Angular single-page applications (SPAs), addressing common navigation issues where scroll position is not predictable.

Abstract

Angular SPAs often struggle with restoring scroll positions during navigation, leading to a suboptimal user experience. The default scrollPositionRestoration option in Angular's RouterModule has limitations, such as not applying to secondary viewports or handling asynchronous data loading well. The article presents a reactive approach via RxJS that allows for a more tailored and reliable scroll position restoration, particularly for content that is loaded asynchronously or within secondary routes. This solution leverages RxJS operators to filter and manage navigation events, capturing and restoring scroll positions based on the navigation cycle and the type of navigation trigger (imperative or popstate). The approach is designed to be declarative and avoids storing state outside of the stream, ensuring that the scroll position is restored correctly after dynamic content has been loaded.

Opinions

  • The author believes that the Angular team's current solution for scroll position restoration is insufficient for many applications, necessitating a more robust approach.
  • The article suggests that a reactive framework like Angular should have a reactive solution for scroll restoration, aligning with the framework's design principles.
  • The author values the use of RxJS for its ability to handle complex asynchronous operations, such as managing scroll positions in a SPA.
  • There is an appreciation for the interoperability between Angular and RxJS, which is highlighted as one of the favorite aspects of working with Angular.
  • The author acknowledges the limitations of the proposed solution, noting that it is not a one-size-fits-all but rather a starting point for developers to address scroll position issues in their applications.
  • The article implies that imperative solutions (directly manipulating the DOM) for scroll restoration can be problematic, and a reactive, stream-based approach is preferable.
  • The author expresses gratitude for the support and input from other developers, Alexander Poshtaruk and Max Koretskyi, as well as inspiration from Ben Nadel's work on the topic.

Reactive Scroll Position Restoration with RxJS

Time-lapse of road travel by Pat Kay

This is a reactive Angular solution via RxJS for simple applications that suffer from a common navigation issue. Similar implementations could be done in other frameworks too.

AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!

SPAs Struggle to Restore Scroll Position

JavaScript Single Page Applications rely on navigation libraries that use the History API to route through the application. A common issue is that scroll position isn’t as predictable inside of an SPA — scrolling down a page with lots of content and clicking a link places a user half way down on the next page instead of at the top, or when a user hits a back/forward button the last scrolled to position isn’t restored as expected.

The most popular and modern front end JavaScript frameworks (React, Vue, and Angular) all suffer from this UX issue. Most navigation library authors have left the management of scroll position restoration up to users because the desired experience might need to be personalized for each application.

Investigating Angular Options

Angular introduced the scrollPositionRestoration option in the RouterModule to help alleviate the headache of managing scroll position restoration. It works, but it’s hard to make one technique work for all applications, and this solution comes with caveats:

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.

☝️ From Ben Nadel on Restoring And Resetting The Scroll Position Using The NavigationStart Event In Angular 7.0.4

In the Angular repo issue #24547 scrollPositionRestoration has several problems, the Angular team has responded to the requests of the community for a more viable solution, but for right now the work is remains unscheduled.

Brainstorming a Solution

For a simple application the main takeaways are that the scrollPositionRestoration won’t work well for:

  • Content that is loaded in a secondary route to the viewport, like if the app has a fixed header/sidebar
  • Content that is loaded asynchronously
  • The application has pages that require scrolling to view all the content

One of my favorite things about working in an Angular application is it’s interoperability with RxJS. Angular is a reactive framework, so I wanted my solution to be reactive too, and as a reactive solution I want to try and follow these principles:

Angular Component Setup

import { Component } from '@angular/core';
@Component({
  selector: 'app-root',
  template: `
    <app-header></app-header>
    <div>
      <app-side-nav></app-side-nav>
      <main>
        <router-outlet></router-outlet>
      </main>
    </div>
  `
})
export default class AppComponent {}

All of the applications routes are being projected into the router-outlet, that’s where our dynamic content shows up. The main element will be scrolled when we scroll through the pages content. If we get a scrollTop of main, we know the Y scroll position of our dynamic content.

Next we’ll want to inject the Router into the component and subscribe to it’s Observable stream of events, we’ll do that inside the OnInit lifecycle hook. We can also set up the pipe off of the stream so that we can begin to craft our declarations with RxJS operators.

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
@Component({
  selector: 'app-root',
  template: `
    <app-header></app-header>
    <div>
      <app-side-nav></app-side-nav>
      <main>
        <router-outlet></router-outlet>
      </main>
    </div>
  `
})
export default class AppComponent implements OnInit {
  constructor(private router: Router) {}
  ngOnInit() {
    this.router.events.pipe(
      // RxJS operators go here
    ).subscribe()
  }
}

Now we can start crafting our declarative stream with RxJS operators.

Declaring What to Do With The Events Observable

First, we only care about the events NavigationStart and NavigationEnd, so let’s import those and the RxJS operator filter. This will let us restrict the stream to only let the router events we care about travel through, based upon a predicate we define:

import { NavigationEnd, NavigationStart } from '@angular/router';
import { filter } from 'rxjs/operators';
this.router.events.pipe(
  filter(
    event =>
      event instanceof NavigationStart ||
      event instanceof NavigationEnd,
  ),
)

Next will be the largest of our functions where we’ll derive most of the information we need. We’re going to use the RxJS operator scan, and we want to model the output of that function so we can utilize TypeScript type inference further down the stream. Here’s the interface we’ll use:

import { Event } from '@angular/router';
interface ScrollPositionRestore {
  event: Event;
  positions: { [K: number]: number };
  trigger: 'imperative' | 'popstate';
  idToRestore: number;
}

This is also when we need to capture main's scrollTop value. To capture that we’ll use a ViewChild and query for a template reference, let’s set that up:

import { ElementRef, ViewChild } from '@angular/core';
@Component({
  selector: 'app-root',
  template: `
    <app-header>
    <div>
      <app-side-nav></app-side-nav>
      <main #contentArea> // adding a template reference
        <router-outlet></router-outlet>
      </main>
    </div>
  `
})
export class AppComponent implements OnInit {
  @ViewChild('contentArea')
  private contentArea: ElementRef<HTMLMainElement>;

Now for the scan, which is like a reduce over time.

import { scan } from 'rxjs/operators';

scan<Event, ScrollPositionRestore>((acc, event) => ({
  event, // either NavigationStart or NavigationEnd
  positions: {
    ...acc.positions, // Spread out previously tracked positions
    ...(event instanceof NavigationStart
      ? { // get snapshot of scrollTop before nav cycle finishes
          [event.id]: this.contentArea.nativeElement.scrollTop,
        }
      : {}),
  },
  trigger: // the type of nav, link click? back button?
    event instanceof NavigationStart
      ? event.navigationTrigger
      : acc.trigger,
  idToRestore:
    // allows us to grab the correct position to restore
    (event instanceof NavigationStart &&
      event.restoredState &&
      event.restoredState.navigationId + 1) ||
    acc.idToRestore,
})),

A few notes on the decisions made for this operation:

  • event — We keep the event to be used in the next step of the stream.
  • positions — Accumulated scroll positions are managed automatically by the scan, and this is when we grab a snapshot of the scrollTop before the navigation cycle completes. I chose an Object instead of an Array because it was easier to work with and didn’t require me to manage anything outside of the stream.
  • trigger — This will be either imperative or popstate (at least for our simple application). If it’s imperative, the user has clicked a link, and we’ll need to make sure the user is set to the top of the next page. If it’s popstate, the user has clicked a back/forward button in their browser, and we want to restore the correct scroll position.
  • idToRestore — Every navigation cycle has an id associated with it. The cycle begins with the NavigationStart event and for example it could have an { id: 2 }. When we capture the scrollTop it’s associated with that { id: 2 }, but the scrolling the user did was before that navigation cycle begins, so their scroll position is actually for the previous navigation cycle (which had already completed), in this example { id: 1 }. When navigating via a popstate trigger, there is a navigationId in the events restoredState that refers to the correct navigation cycle where the scrolling occurred, but the scroll position is being mapped to the next cycle. Somewhere there needs to be a + or of 1, and I chose to do a +1 here to match the scroll position with the id it was mapped to.

That work targeted what we needed to do for a NavigationStart event. For the next step we only care about the NavigationEnd event since we restore scroll position after the navigation cycle completes, and we want to make sure we have a trigger, so let’s use another filter:

filter(
  ({ event, trigger }) => 
    event instanceof NavigationEnd && !!trigger,
),

The last remaining pieces are to set the scrollTop correctly and to make sure that the work is fired at the correct time. We’re dealing with dynamically loaded content and we need to scroll properly after content has been loaded. First let’s see what setting the scrollTop looks like in the subscription block:

.subscribe(({ trigger, positions, idToRestore }) => {
  if (trigger === 'imperative') {
    this
      .contentArea
      .nativeElement
      .scrollTop = 0;
  }
  if (trigger === 'popstate') {
    this
      .contentArea
      .nativeElement
      .scrollTop = positions[idToRestore];
  }
});

The final touch is to queue the work correctly with the navigation cycle and the lifecycle’s of the activated route component. We want to scroll after our dynamic view is ready, which would be denoted by the Component AfterViewInit lifecycle hook.

To correctly queue the work we can use the RxJS operator observeOn and pass in the RxJS asyncScheduler just before the subscription block. This will let the browser event-loop defer execution of the subscription block to the proper time, in our case just after the AfterViewInit lifecycle hook:

import { asyncScheduler } from 'rxjs';
import { observeOn } from 'rxjs/operators';
observeOn(asyncScheduler),

You can learn more about the observeOn operator and the asyncScheduler in Alexander Poshtaruk’s article RxJS: applying asyncScheduler as an argument vs with observeOn operator.

Putting It All Together

Conclusion

This is by no means a comprehensive solution for all Angular applications, but it’s some low-hanging fruit that should get you started in fixing that bad UX with scroll positions for dynamic content.

Originally I wanted to pipe the scrollTop value directly into the template, but I had odd issues where that wasn’t working correctly, so I fell back to subscribing to the stream and imperatively setting what I needed to.

For a solution that covers more complex scenarios check out Ben Nadel’s article on Restoring And Resetting The Scroll Position Using The NavigationStart Event In Angular 7.0.4.

Happy scrolling 🤘

Thanks to Alexander Poshtaruk and Max Koretskyi for the support and review, and Ben Nadel for the inspiration.

Find me elsewhere as @jsonberry on Twitter and Github

JavaScript
Rxjs
Angular
Typescript
Web Development
Recommended from ReadMedium