question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. ItĀ collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Improve router work with `history.state`

See original GitHub issue

šŸš€ 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 https://github.com/angular/angular/pull/27198#issuecomment-453789450 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();
  }
}

Issue Analytics

  • State:open
  • Created 5 years ago
  • Reactions:38
  • Comments:13 (5 by maintainers)

github_iconTop GitHub Comments

27reactions
eric-gagnoncommented, May 16, 2019

+1

I stumbled on the very same problem. When reloading page, state seem to be lost.

5reactions
colmbencommented, Mar 28, 2019

I am currently trying to implement a reasonable back button solution that would also allow some state manipulation in-page. The current way state and popstates are handled is very…peculiar!

As this request says, the navigation process now has the ability to set state. Instead of just popping the stored state, location.back() actually creates a new navigation with a restoredState property in the NavigationStart event. Why?

If you change the state with location.replaceState(), location.back wouldn’t even find that state to restore, unless you also merge in the (correct? Not sure it needs to be the actual right number) navigationId. Why does it do this?

What I was expecting to find when I saw these changes was an Angular implementation of the History API , which allows any arbitrary state to be placed in history and that state to be immediately available on a pop. Both Location and Router are using names that seem to be related to the History API (replaceState, back etc) but they aren’t doing what you would expect from a History API compliant process. Why are we complicating the thing with navigation IDs and restored states? It feels like Location and Router are at cross purposes here and one or the other should properly implement the History API to allow intuitive state management over pops.

Read more comments on GitHub >

github_iconTop Results From Across the Web

How to properly work with Router using history to pass ...
Specifically the issue is that the state is passed to the Link , from React Router, but then you attempt to access the...
Read more >
Why you don't need to mix routing state with Redux
In this post, we cover how Redux-first routing works and explain how Redux can make your code more complicated than it needs to...
Read more >
RouterLink - Angular
You can use absolute or relative paths in a link, set query parameters, control how parameters are handled, and keep a history of...
Read more >
Modern client-side routing: the Navigation API
This is intended as an improved aggregation of older methods like location.assign() and friends, plus the History API's methods pushState() and replaceState() ....
Read more >
Backbone.js
History serves as a global router (per frame) to handle hashchange events or pushState, match the appropriate route, and trigger callbacks. You shouldn't...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found