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:
- Created 5 years ago
- Reactions:38
- Comments:13 (5 by maintainers)
+1
I stumbled on the very same problem. When reloading page, state seem to be lost.
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.