Skip to content

Improve router work with history.state #28954

@samogot

Description

@samogot

🚀 feature request

Relevant Package

This feature request is for @angular/router

Disclaimer

This feature request can be broken down to several separate issues (bugs and FRs) but I've decided to create one issue to describe my actual use case, problems I have encountered, ideal solution I'd like to have and workaround I came up with.

Use case

I'm working on web site for reading books. I have some "reader" page with infinite scroll that loads next chapter when user scrolls near to of a page. When some chapter enters view-port, I change browser URL to this chapter without triggering navigation (using Location.replaceState).

If users scroll to some next chapter and then reload page or go back/forward in history, they lost scroll position, because saved scroll position (on page with several chapters) is greater then reloaded page length (on page with only "last" chapter)

The idea was to use history.state to save scroll position relative to current chapter top.

Problems

Saving position was pretty straightforward, because Location.replaceState method already has state property. But restoring this state is a pain.

Thanks to #27198 router already knows something about history state, current design/implementation isn't enough do described use case. I didn't figure out why we need both NavigationStart.restoredState and NavigationExtras.state, but despite similar description, they are not the same.

Router itself lacks public method that behaves like Location.replaceState (#24617), and any "outside" changes to history.state are not available as NavigationExtras.state of Router.getCurrentNavigation(), but only available as NavigationStart.restoredState. There is private setBrowserUrl method, that can be used for public implementation.

NavigationStart.restoredState of initial page load can't be reached inside component that is responsible for text rendering (i.e. in the place, which knows chapter top offset, where we need to add saved scroll position), because initial NavigationStart is fired before any component (except of AppComponent) is created. And there is no way to get this value otherwise, except of catching it in AppComponent and saving in some service.

Buy the way workaround from #27198 (comment) to pass NavigationStart.restoredState as NavigationExtras.state doesn't work (any more?) because of resetting currentNavigation here in case of "redirect during NavigationStart".

And even if we can get NavigationStart.restoredState in such hackish way, there is no sense to do this, because of bug. We don't get load any stored state from history in initial navigation (see null in line 951 called from line 778)

Actually we don't even have a way to get this initial state, because all classes on the way from Location, through LocationStrategy, PathLocationStrategy, PlatformLocation, BrowserPlatformLocation up to History lacks an abstraction to get current state.

Hopefully we still can access window.history.state directly. But again, we cann't do it in the component code, because before we create component, router already resets saves history.state with {navigationId:1}. So I still need to write some hackish way to store this value somewhere before first ActivationStart event.

Describe the solution you'd like

I'd like to have a way to straightforwardly call something on router to replace current state and than be able to get this state in Scroll router event. I want this state to be persistently saved even after page reload to moving back/forward outside of angular application.

I don't really care how it will be implemented internally, but just fixing problems above should be enough.

Router should have it's own replaceState (or something) method, that encapsulate any logic around Location.replaceState to handle navigationId or any other possible future angular-specific internal structure of history.state (#27607) and also to provide state as NavigationExtras.state instead of NavigationStart.restoredState.

Describe alternatives you've considered

This section is mostly for someone looking for solution for exactly my use case.
Workaround I came up with consists of two parts.
First - I use resolver to get saved data directly from window.history.state.
Second - because of the same reason I can get data on initial page load - I receive wrong data on navigating away to another chapter. What do I mean? Consider following use case:

  • Initial page load without history.
  • No data in history -> no scrolling to position.
  • Scroll a bit, position is saved to history.
  • Reload page.
  • Router will rewrite history only on ActivationStart, so we get correct data in resolver.
  • There is data from resolver -> scroll to saved position.
  • Scroll a bit, position is saved to history.
  • Navigate to another chapter directly (there is contents feature in my app).
  • Router will rewrite history only on ActivationStart, so we get incorrect data of previous chapter in resolver.
  • There is data from resolver -> we scroll to incorrect saved position of previous chapter.

To workaround this problem, I save chapter id together with scroll position, so I can check is this data is relevant.
Final code looks something like this:

progress-resolver.service.ts:

import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from "@angular/common";
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from "@angular/router";

interface Progress {
  scroll: number,
  chapterId: number,
}

@Injectable({
  providedIn: 'root'
})
export class ProgressResolverService implements Resolve<Progress | null> {

  constructor(@Inject(PLATFORM_ID) private platformId: Object) {
  }

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Progress | null {
    if (isPlatformBrowser(this.platformId) && window && window.history && window.history.state && window.history.state.progress) {
      return {
        scroll: window.history.state.scroll,
        chapterId: window.history.state.chapterId,
      };
    }
    return null;
  }
}

chapter-text.component.ts:

import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core';
import { OnScrollService } from "../on-scroll/on-scroll.service";
import { ActivatedRoute } from "@angular/router";
import { Location, ViewportScroller } from "@angular/common";
import { debounceTime, filter, map } from "rxjs/operators";
import { Subscription } from "rxjs";

@Component({
  selector: 'app-chapter-text',
  templateUrl: './chapter-text.component.html',
  styleUrls: ['./chapter-text.component.scss']
})
export class ChapterTextComponent implements OnDestroy, AfterViewInit {
  @Input() chapter: any;
  private progressSub: Subscription;
  private subscription: Subscription;
  private position: { top: number; height: number };

  constructor(
    private elem: ElementRef,
    private route: ActivatedRoute,
    private onScroll: OnScrollService,
    private location: Location,
    private viewportScroller: ViewportScroller,
  ) {
  }

  ngAfterViewInit() {
    this.position = {
      top: this.elem.nativeElement.offsetTop,
      height: this.elem.nativeElement.offsetHeight
    };
    this.subscribe();
  }

  subscribe() {
    this.subscription = this.onScroll.scroll.pipe(
      filter(scroll => this.position.top < scroll && scroll < this.position.top + this.position.height),
      debounceTime(100),
    ).subscribe(scroll => this.location.replaceState(this.chapter.url, undefined, {
      ...window.history.state,
      scroll: scroll - this.position.top,
      chapterId: this.chapter.chapterId
    }));
    
    this.progressSub = this.route.data.pipe(
      map(data => data.progress),
      filter(progress => progress && this.chapter.chapterId == progress.chapterId),
      map(progress => progress.scroll),
    ).subscribe(scroll => this.viewportScroller.scrollToPosition([0, this.position.top + scroll]));
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
    this.progressSub.unsubscribe();
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: routerfeatureLabel used to distinguish feature request from other issuesfeature: under considerationFeature request for which voting has completed and the request is now under considerationfreq3: high

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions