feat(router): implement scrolling restoration service (#20030)

For documentation, see `RouterModule.scrollPositionRestoration`

Fixes #13636 #10929 #7791 #6595

PR Close #20030
This commit is contained in:
Victor Savkin 2018-05-17 07:33:50 -04:00 committed by Miško Hevery
parent 1b253e14ff
commit 49c5234c68
13 changed files with 662 additions and 11 deletions

View File

@ -25,3 +25,4 @@ export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCase
export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index'; export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index';
export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id'; export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id';
export {VERSION} from './version'; export {VERSION} from './version';
export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller';

View File

@ -0,0 +1,183 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {defineInjectable, inject} from '@angular/core';
import {DOCUMENT} from './dom_tokens';
/**
* @whatItDoes Manages the scroll position.
*/
export abstract class ViewportScroller {
// De-sugared tree-shakable injection
// See #23917
/** @nocollapse */
static ngInjectableDef = defineInjectable(
{providedIn: 'root', factory: () => new BrowserViewportScroller(inject(DOCUMENT), window)});
/**
* @whatItDoes Configures the top offset used when scrolling to an anchor.
*
* When given a tuple with two number, the service will always use the numbers.
* When given a function, the service will invoke the function every time it restores scroll
* position.
*/
abstract setOffset(offset: [number, number]|(() => [number, number])): void;
/**
* @whatItDoes Returns the current scroll position.
*/
abstract getScrollPosition(): [number, number];
/**
* @whatItDoes Sets the scroll position.
*/
abstract scrollToPosition(position: [number, number]): void;
/**
* @whatItDoes Scrolls to the provided anchor.
*/
abstract scrollToAnchor(anchor: string): void;
/**
* @whatItDoes Disables automatic scroll restoration provided by the browser.
* See also [window.history.scrollRestoration
* info](https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration)
*/
abstract setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void;
}
/**
* @whatItDoes Manages the scroll position.
*/
export class BrowserViewportScroller implements ViewportScroller {
private offset: () => [number, number] = () => [0, 0];
constructor(private document: any, private window: any) {}
/**
* @whatItDoes Configures the top offset used when scrolling to an anchor.
*
* * When given a number, the service will always use the number.
* * When given a function, the service will invoke the function every time it restores scroll
* position.
*/
setOffset(offset: [number, number]|(() => [number, number])): void {
if (Array.isArray(offset)) {
this.offset = () => offset;
} else {
this.offset = offset;
}
}
/**
* @whatItDoes Returns the current scroll position.
*/
getScrollPosition(): [number, number] {
if (this.supportScrollRestoration()) {
return [this.window.scrollX, this.window.scrollY];
} else {
return [0, 0];
}
}
/**
* @whatItDoes Sets the scroll position.
*/
scrollToPosition(position: [number, number]): void {
if (this.supportScrollRestoration()) {
this.window.scrollTo(position[0], position[1]);
}
}
/**
* @whatItDoes Scrolls to the provided anchor.
*/
scrollToAnchor(anchor: string): void {
if (this.supportScrollRestoration()) {
const elSelectedById = this.document.querySelector(`#${anchor}`);
if (elSelectedById) {
this.scrollToElement(elSelectedById);
return;
}
const elSelectedByName = this.document.querySelector(`[name='${anchor}']`);
if (elSelectedByName) {
this.scrollToElement(elSelectedByName);
return;
}
}
}
/**
* @whatItDoes Disables automatic scroll restoration provided by the browser.
*/
setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {
if (this.supportScrollRestoration()) {
const history = this.window.history;
if (history && history.scrollRestoration) {
history.scrollRestoration = scrollRestoration;
}
}
}
private scrollToElement(el: any): void {
const rect = el.getBoundingClientRect();
const left = rect.left + this.window.pageXOffset;
const top = rect.top + this.window.pageYOffset;
const offset = this.offset();
this.window.scrollTo(left - offset[0], top - offset[1]);
}
/**
* We only support scroll restoration when we can get a hold of window.
* This means that we do not support this behavior when running in a web worker.
*
* Lifting this restriction right now would require more changes in the dom adapter.
* Since webworkers aren't widely used, we will lift it once RouterScroller is
* battle-tested.
*/
private supportScrollRestoration(): boolean {
try {
return !!this.window && !!this.window.scrollTo;
} catch (e) {
return false;
}
}
}
/**
* @whatItDoes Provides an empty implementation of the viewport scroller. This will
* live in @angular/common as it will be used by both platform-server and platform-webworker.
*/
export class NullViewportScroller implements ViewportScroller {
/**
* @whatItDoes empty implementation
*/
setOffset(offset: [number, number]|(() => [number, number])): void {}
/**
* @whatItDoes empty implementation
*/
getScrollPosition(): [number, number] { return [0, 0]; }
/**
* @whatItDoes empty implementation
*/
scrollToPosition(position: [number, number]): void {}
/**
* @whatItDoes empty implementation
*/
scrollToAnchor(anchor: string): void {}
/**
* @whatItDoes empty implementation
*/
setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {}
}

View File

@ -7,7 +7,7 @@
*/ */
import {ɵAnimationEngine} from '@angular/animations/browser'; import {ɵAnimationEngine} from '@angular/animations/browser';
import {PlatformLocation, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common'; import {PlatformLocation, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common';
import {HttpClientModule} from '@angular/common/http'; import {HttpClientModule} from '@angular/common/http';
import {Injectable, InjectionToken, Injector, NgModule, NgZone, Optional, PLATFORM_ID, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactory2, RootRenderer, StaticProvider, Testability, createPlatformFactory, isDevMode, platformCore, ɵALLOW_MULTIPLE_PLATFORMS as ALLOW_MULTIPLE_PLATFORMS} from '@angular/core'; import {Injectable, InjectionToken, Injector, NgModule, NgZone, Optional, PLATFORM_ID, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactory2, RootRenderer, StaticProvider, Testability, createPlatformFactory, isDevMode, platformCore, ɵALLOW_MULTIPLE_PLATFORMS as ALLOW_MULTIPLE_PLATFORMS} from '@angular/core';
import {HttpModule} from '@angular/http'; import {HttpModule} from '@angular/http';
@ -74,6 +74,7 @@ export const SERVER_RENDER_PROVIDERS: Provider[] = [
SERVER_RENDER_PROVIDERS, SERVER_RENDER_PROVIDERS,
SERVER_HTTP_PROVIDERS, SERVER_HTTP_PROVIDERS,
{provide: Testability, useValue: null}, {provide: Testability, useValue: null},
{provide: ViewportScroller, useClass: NullViewportScroller},
], ],
}) })
export class ServerModule { export class ServerModule {

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {CommonModule, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common'; import {CommonModule, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common';
import {APP_INITIALIZER, ApplicationModule, ErrorHandler, NgModule, NgZone, PLATFORM_ID, PlatformRef, RendererFactory2, RootRenderer, StaticProvider, createPlatformFactory, platformCore} from '@angular/core'; import {APP_INITIALIZER, ApplicationModule, ErrorHandler, NgModule, NgZone, PLATFORM_ID, PlatformRef, RendererFactory2, RootRenderer, StaticProvider, createPlatformFactory, platformCore} from '@angular/core';
import {DOCUMENT, ɵBROWSER_SANITIZATION_PROVIDERS as BROWSER_SANITIZATION_PROVIDERS} from '@angular/platform-browser'; import {DOCUMENT, ɵBROWSER_SANITIZATION_PROVIDERS as BROWSER_SANITIZATION_PROVIDERS} from '@angular/platform-browser';
@ -71,6 +71,7 @@ export function setupWebWorker(): void {
{provide: ErrorHandler, useFactory: errorHandler, deps: []}, {provide: ErrorHandler, useFactory: errorHandler, deps: []},
{provide: MessageBus, useFactory: createMessageBus, deps: [NgZone]}, {provide: MessageBus, useFactory: createMessageBus, deps: [NgZone]},
{provide: APP_INITIALIZER, useValue: setupWebWorker, multi: true}, {provide: APP_INITIALIZER, useValue: setupWebWorker, multi: true},
{provide: ViewportScroller, useClass: NullViewportScroller, deps: []},
], ],
exports: [ exports: [
CommonModule, CommonModule,

View File

@ -401,6 +401,28 @@ export class ActivationEnd {
} }
} }
/**
* @description
*
* Represents a scrolling event.
*/
export class Scroll {
constructor(
/** @docsNotRequired */
readonly routerEvent: NavigationEnd,
/** @docsNotRequired */
readonly position: [number, number]|null,
/** @docsNotRequired */
readonly anchor: string|null) {}
toString(): string {
const pos = this.position ? `${this.position[0]}, ${this.position[1]}` : null;
return `Scroll(anchor: '${this.anchor}', position: '${pos}')`;
}
}
/** /**
* @description * @description
* *
@ -423,8 +445,9 @@ export class ActivationEnd {
* - `NavigationEnd`, * - `NavigationEnd`,
* - `NavigationCancel`, * - `NavigationCancel`,
* - `NavigationError` * - `NavigationError`
* - `Scroll`
* *
* *
*/ */
export type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | export type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart |
ChildActivationEnd | ActivationStart | ActivationEnd; ChildActivationEnd | ActivationStart | ActivationEnd | Scroll;

View File

@ -11,7 +11,7 @@ export {Data, LoadChildren, LoadChildrenCallback, ResolveData, Route, Routes, Ru
export {RouterLink, RouterLinkWithHref} from './directives/router_link'; export {RouterLink, RouterLinkWithHref} from './directives/router_link';
export {RouterLinkActive} from './directives/router_link_active'; export {RouterLinkActive} from './directives/router_link_active';
export {RouterOutlet} from './directives/router_outlet'; export {RouterOutlet} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized} from './events'; export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces';
export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {NavigationExtras, Router} from './router'; export {NavigationExtras, Router} from './router';

View File

@ -543,7 +543,6 @@ export class Router {
rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null, rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null,
extras: NavigationExtras): Promise<boolean> { extras: NavigationExtras): Promise<boolean> {
const lastNavigation = this.navigations.value; const lastNavigation = this.navigations.value;
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
// and that navigation results in 'replaceState' that leads to the same URL, // and that navigation results in 'replaceState' that leads to the same URL,
// we should skip those. // we should skip those.

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {APP_BASE_HREF, HashLocationStrategy, LOCATION_INITIALIZED, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common'; import {APP_BASE_HREF, HashLocationStrategy, LOCATION_INITIALIZED, Location, LocationStrategy, PathLocationStrategy, PlatformLocation, ViewportScroller} from '@angular/common';
import {ANALYZE_FOR_ENTRY_COMPONENTS, APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, Compiler, ComponentRef, Inject, Injectable, InjectionToken, Injector, ModuleWithProviders, NgModule, NgModuleFactoryLoader, NgProbeToken, Optional, Provider, SkipSelf, SystemJsNgModuleLoader} from '@angular/core'; import {ANALYZE_FOR_ENTRY_COMPONENTS, APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, Compiler, ComponentRef, Inject, Injectable, InjectionToken, Injector, ModuleWithProviders, NgModule, NgModuleFactoryLoader, NgProbeToken, Optional, Provider, SkipSelf, SystemJsNgModuleLoader} from '@angular/core';
import {ɵgetDOM as getDOM} from '@angular/platform-browser'; import {ɵgetDOM as getDOM} from '@angular/platform-browser';
import {Subject, of } from 'rxjs'; import {Subject, of } from 'rxjs';
@ -21,6 +21,7 @@ import {ErrorHandler, Router} from './router';
import {ROUTES} from './router_config_loader'; import {ROUTES} from './router_config_loader';
import {ChildrenOutletContexts} from './router_outlet_context'; import {ChildrenOutletContexts} from './router_outlet_context';
import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader'; import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader';
import {RouterScroller} from './router_scroller';
import {ActivatedRoute} from './router_state'; import {ActivatedRoute} from './router_state';
import {UrlHandlingStrategy} from './url_handling_strategy'; import {UrlHandlingStrategy} from './url_handling_strategy';
import {DefaultUrlSerializer, UrlSerializer} from './url_tree'; import {DefaultUrlSerializer, UrlSerializer} from './url_tree';
@ -165,6 +166,11 @@ export class RouterModule {
PlatformLocation, [new Inject(APP_BASE_HREF), new Optional()], ROUTER_CONFIGURATION PlatformLocation, [new Inject(APP_BASE_HREF), new Optional()], ROUTER_CONFIGURATION
] ]
}, },
{
provide: RouterScroller,
useFactory: createRouterScroller,
deps: [Router, ViewportScroller, ROUTER_CONFIGURATION]
},
{ {
provide: PreloadingStrategy, provide: PreloadingStrategy,
useExisting: config && config.preloadingStrategy ? config.preloadingStrategy : useExisting: config && config.preloadingStrategy ? config.preloadingStrategy :
@ -184,6 +190,14 @@ export class RouterModule {
} }
} }
export function createRouterScroller(
router: Router, viewportScroller: ViewportScroller, config: ExtraOptions): RouterScroller {
if (config.scrollOffset) {
viewportScroller.setOffset(config.scrollOffset);
}
return new RouterScroller(router, viewportScroller, config);
}
export function provideLocationStrategy( export function provideLocationStrategy(
platformLocationStrategy: PlatformLocation, baseHref: string, options: ExtraOptions = {}) { platformLocationStrategy: PlatformLocation, baseHref: string, options: ExtraOptions = {}) {
return options.useHash ? new HashLocationStrategy(platformLocationStrategy, baseHref) : return options.useHash ? new HashLocationStrategy(platformLocationStrategy, baseHref) :
@ -291,6 +305,77 @@ export interface ExtraOptions {
*/ */
onSameUrlNavigation?: 'reload'|'ignore'; onSameUrlNavigation?: 'reload'|'ignore';
/**
* Configures if the scroll position needs to be restored when navigating back.
*
* * 'disabled'--does nothing (default).
* * 'top'--set the scroll position to 0,0..
* * 'enabled'--set the scroll position to the stored position. This option will be the default in
* the future.
*
* When enabled, the router store store scroll positions when navigating forward, and will
* restore the stored positions whe navigating back (popstate). When navigating forward,
* the scroll position will be set to [0, 0], or to the anchor if one is provided.
*
* You can implement custom scroll restoration behavior as follows.
* ```typescript
* class AppModule {
* constructor(router: Router, viewportScroller: ViewportScroller, store: Store<AppState>) {
* router.events.pipe(filter(e => e instanceof Scroll), switchMap(e => {
* return store.pipe(first(), timeout(200), map(() => e));
* }).subscribe(e => {
* if (e.position) {
* viewportScroller.scrollToPosition(e.position);
* } else if (e.anchor) {
* viewportScroller.scrollToAnchor(e.anchor);
* } else {
* viewportScroller.scrollToPosition([0, 0]);
* }
* });
* }
* }
* ```
*
* You can also implement component-specific scrolling like this:
*
* ```typescript
* class ListComponent {
* list: any[];
* constructor(router: Router, viewportScroller: ViewportScroller, fetcher: ListFetcher) {
* const scrollEvents = router.events.filter(e => e instanceof Scroll);
* listFetcher.fetch().pipe(withLatestFrom(scrollEvents)).subscribe(([list, e]) => {
* this.list = list;
* if (e.position) {
* viewportScroller.scrollToPosition(e.position);
* } else {
* viewportScroller.scrollToPosition([0, 0]);
* }
* });
* }
* }
*/
scrollPositionRestoration?: 'disabled'|'enabled'|'top';
/**
* Configures if the router should scroll to the element when the url has a fragment.
*
* * 'disabled'--does nothing (default).
* * 'enabled'--scrolls to the element. This option will be the default in the future.
*
* Anchor scrolling does not happen on 'popstate'. Instead, we restore the position
* that we stored or scroll to the top.
*/
anchorScrolling?: 'disabled'|'enabled';
/**
* Configures the scroll offset the router will use when scrolling to an element.
*
* When given a tuple with two numbers, the router will always use the numbers.
* When given a function, the router will invoke the function every time it restores scroll
* position.
*/
scrollOffset?: [number, number]|(() => [number, number]);
/** /**
* Defines how the router merges params, data and resolved data from parent to child * Defines how the router merges params, data and resolved data from parent to child
* routes. Available options are: * routes. Available options are:
@ -406,6 +491,7 @@ export class RouterInitializer {
bootstrapListener(bootstrappedComponentRef: ComponentRef<any>): void { bootstrapListener(bootstrappedComponentRef: ComponentRef<any>): void {
const opts = this.injector.get(ROUTER_CONFIGURATION); const opts = this.injector.get(ROUTER_CONFIGURATION);
const preloader = this.injector.get(RouterPreloader); const preloader = this.injector.get(RouterPreloader);
const routerScroller = this.injector.get(RouterScroller);
const router = this.injector.get(Router); const router = this.injector.get(Router);
const ref = this.injector.get<ApplicationRef>(ApplicationRef); const ref = this.injector.get<ApplicationRef>(ApplicationRef);
@ -420,6 +506,7 @@ export class RouterInitializer {
} }
preloader.setUpPreloading(); preloader.setUpPreloading();
routerScroller.init();
router.resetRootComponentType(ref.componentTypes[0]); router.resetRootComponentType(ref.componentTypes[0]);
this.resultOfPreactivationDone.next(null !); this.resultOfPreactivationDone.next(null !);
this.resultOfPreactivationDone.complete(); this.resultOfPreactivationDone.complete();

View File

@ -0,0 +1,91 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ViewportScroller} from '@angular/common';
import {OnDestroy} from '@angular/core';
import {Unsubscribable} from 'rxjs';
import {NavigationEnd, NavigationStart, Scroll} from './events';
import {Router} from './router';
export class RouterScroller implements OnDestroy {
private routerEventsSubscription: Unsubscribable;
private scrollEventsSubscription: Unsubscribable;
private lastId = 0;
private lastSource: 'imperative'|'popstate'|'hashchange'|undefined = 'imperative';
private restoredId = 0;
private store: {[key: string]: [number, number]} = {};
constructor(
private router: Router,
/** @docsNotRequired */ public readonly viewportScroller: ViewportScroller, private options: {
scrollPositionRestoration?: 'disabled' | 'enabled' | 'top',
anchorScrolling?: 'disabled'|'enabled'
} = {}) {}
init(): void {
// we want to disable the automatic scrolling because having two places
// responsible for scrolling results race conditions, especially given
// that browser don't implement this behavior consistently
if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.setHistoryScrollRestoration('manual');
}
this.routerEventsSubscription = this.createScrollEvents();
this.scrollEventsSubscription = this.consumeScrollEvents();
}
private createScrollEvents() {
return this.router.events.subscribe(e => {
if (e instanceof NavigationStart) {
// store the scroll position of the current stable navigations.
this.store[this.lastId] = this.viewportScroller.getScrollPosition();
this.lastSource = e.navigationTrigger;
this.restoredId = e.restoredState ? e.restoredState.navigationId : 0;
} else if (e instanceof NavigationEnd) {
this.lastId = e.id;
this.scheduleScrollEvent(e, this.router.parseUrl(e.urlAfterRedirects).fragment);
}
});
}
private consumeScrollEvents() {
return this.router.events.subscribe(e => {
if (!(e instanceof Scroll)) return;
// a popstate event. The pop state event will always ignore anchor scrolling.
if (e.position) {
if (this.options.scrollPositionRestoration === 'top') {
this.viewportScroller.scrollToPosition([0, 0]);
} else if (this.options.scrollPositionRestoration === 'enabled') {
this.viewportScroller.scrollToPosition(e.position);
}
// imperative navigation "forward"
} else {
if (e.anchor && this.options.anchorScrolling === 'enabled') {
this.viewportScroller.scrollToAnchor(e.anchor);
} else if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.scrollToPosition([0, 0]);
}
}
});
}
private scheduleScrollEvent(routerEvent: NavigationEnd, anchor: string|null): void {
this.router.triggerEvent(new Scroll(
routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null, anchor));
}
ngOnDestroy() {
if (this.routerEventsSubscription) {
this.routerEventsSubscription.unsubscribe();
}
if (this.scrollEventsSubscription) {
this.scrollEventsSubscription.unsubscribe();
}
}
}

View File

@ -6,13 +6,13 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {APP_BASE_HREF} from '@angular/common'; import {APP_BASE_HREF, Location, ViewportScroller} from '@angular/common';
import {ApplicationRef, CUSTOM_ELEMENTS_SCHEMA, Component, NgModule, destroyPlatform} from '@angular/core'; import {ApplicationRef, CUSTOM_ELEMENTS_SCHEMA, Component, NgModule, destroyPlatform} from '@angular/core';
import {inject} from '@angular/core/testing'; import {inject} from '@angular/core/testing';
import {BrowserModule, DOCUMENT, ɵgetDOM as getDOM} from '@angular/platform-browser'; import {BrowserModule, DOCUMENT, ɵgetDOM as getDOM} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {NavigationEnd, Resolve, Router, RouterModule} from '@angular/router'; import {NavigationEnd, NavigationStart, Resolve, Router, RouterModule} from '@angular/router';
import {filter, first} from 'rxjs/operators';
describe('bootstrap', () => { describe('bootstrap', () => {
if (isNode) return; if (isNode) return;
@ -86,7 +86,7 @@ describe('bootstrap', () => {
expect(log).toEqual([ expect(log).toEqual([
'TestModule', 'NavigationStart', 'RoutesRecognized', 'GuardsCheckStart', 'TestModule', 'NavigationStart', 'RoutesRecognized', 'GuardsCheckStart',
'ChildActivationStart', 'ActivationStart', 'GuardsCheckEnd', 'ResolveStart', 'ResolveEnd', 'ChildActivationStart', 'ActivationStart', 'GuardsCheckEnd', 'ResolveStart', 'ResolveEnd',
'RootCmp', 'ActivationEnd', 'ChildActivationEnd', 'NavigationEnd' 'RootCmp', 'ActivationEnd', 'ChildActivationEnd', 'NavigationEnd', 'Scroll'
]); ]);
done(); done();
}); });
@ -248,4 +248,78 @@ describe('bootstrap', () => {
appRef.components[0].destroy(); appRef.components[0].destroy();
}); });
}); });
it('should restore the scrolling position', async(done) => {
@Component({
selector: 'component-a',
template: `
<div style="height: 3000px;"></div>
<div id="marker1"></div>
<div style="height: 3000px;"></div>
<div id="marker2"></div>
<div style="height: 3000px;"></div>
<a name="marker3"></a>
<div style="height: 3000px;"></div>
`
})
class TallComponent {
}
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[
{path: '', pathMatch: 'full', redirectTo: '/aa'},
{path: 'aa', component: TallComponent}, {path: 'bb', component: TallComponent},
{path: 'cc', component: TallComponent},
{path: 'fail', component: TallComponent, canActivate: ['returnFalse']}
],
{
useHash: true,
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
scrollOffset: [0, 100],
onSameUrlNavigation: 'reload'
})
],
declarations: [TallComponent, RootCmp],
bootstrap: [RootCmp],
providers: [...testProviders, {provide: 'returnFalse', useValue: () => false}],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class TestModule {
}
const res = await platformBrowserDynamic([]).bootstrapModule(TestModule);
const router = res.injector.get(Router);
const location: Location = res.injector.get(Location);
await router.navigateByUrl('/aa');
window.scrollTo(0, 5000);
await router.navigateByUrl('/fail');
expect(window.scrollY).toEqual(5000);
await router.navigateByUrl('/bb');
window.scrollTo(0, 3000);
expect(window.scrollY).toEqual(3000);
await router.navigateByUrl('/cc');
expect(window.scrollY).toEqual(0);
await router.navigateByUrl('/aa#marker2');
expect(window.scrollY >= 5900).toBe(true);
expect(window.scrollY < 6000).toBe(true); // offset
await router.navigateByUrl('/aa#marker3');
expect(window.scrollY >= 8900).toBe(true);
expect(window.scrollY < 9000).toBe(true);
done();
});
function waitForNavigationToComplete(router: Router): Promise<any> {
return router.events.pipe(filter((e: any) => e instanceof NavigationEnd), first()).toPromise();
}
}); });

View File

@ -0,0 +1,168 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {fakeAsync, tick} from '@angular/core/testing';
import {DefaultUrlSerializer, NavigationEnd, NavigationStart, RouterEvent} from '@angular/router';
import {Subject} from 'rxjs';
import {filter, switchMap} from 'rxjs/operators';
import {Scroll} from '../src/events';
import {RouterScroller} from '../src/router_scroller';
describe('RouterScroller', () => {
describe('scroll to top', () => {
it('should scroll to the top', () => {
const {events, viewportScroller} =
createRouterScroller({scrollPositionRestoration: 'top', anchorScrolling: 'disabled'});
events.next(new NavigationStart(1, '/a'));
events.next(new NavigationEnd(1, '/a', '/a'));
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(2, '/a'));
events.next(new NavigationEnd(2, '/b', '/b'));
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(3, '/a', 'popstate'));
events.next(new NavigationEnd(3, '/a', '/a'));
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
});
});
describe('scroll to the stored position', () => {
it('should scroll to the stored position on popstate', () => {
const {events, viewportScroller} =
createRouterScroller({scrollPositionRestoration: 'enabled', anchorScrolling: 'disabled'});
events.next(new NavigationStart(1, '/a'));
events.next(new NavigationEnd(1, '/a', '/a'));
setScroll(viewportScroller, 10, 100);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(2, '/b'));
events.next(new NavigationEnd(2, '/b', '/b'));
setScroll(viewportScroller, 20, 200);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(3, '/a', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(3, '/a', '/a'));
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100]);
});
});
describe('anchor scrolling', () => {
it('should work (scrollPositionRestoration is disabled)', () => {
const {events, viewportScroller} =
createRouterScroller({scrollPositionRestoration: 'disabled', anchorScrolling: 'enabled'});
events.next(new NavigationStart(1, '/a#anchor'));
events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor'));
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor');
events.next(new NavigationStart(2, '/a#anchor2'));
events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2'));
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2');
viewportScroller.scrollToAnchor.calls.reset();
// we never scroll to anchor when navigating back.
events.next(new NavigationStart(3, '/a#anchor', 'popstate'));
events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor'));
expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled();
expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled();
});
it('should work (scrollPositionRestoration is enabled)', () => {
const {events, viewportScroller} =
createRouterScroller({scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled'});
events.next(new NavigationStart(1, '/a#anchor'));
events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor'));
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor');
events.next(new NavigationStart(2, '/a#anchor2'));
events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2'));
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2');
viewportScroller.scrollToAnchor.calls.reset();
// we never scroll to anchor when navigating back
events.next(new NavigationStart(3, '/a#anchor', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor'));
expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled();
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
});
});
describe('extending a scroll service', () => {
it('work', fakeAsync(() => {
const {events, viewportScroller, router} = createRouterScroller(
{scrollPositionRestoration: 'disabled', anchorScrolling: 'disabled'});
router.events
.pipe(filter(e => e instanceof Scroll && !!e.position), switchMap(p => {
// can be any delay (e.g., we can wait for NgRx store to emit an event)
const r = new Subject<any>();
setTimeout(() => {
r.next(p);
r.complete();
}, 1000);
return r;
}))
.subscribe((e: Scroll) => { viewportScroller.scrollToPosition(e.position); });
events.next(new NavigationStart(1, '/a'));
events.next(new NavigationEnd(1, '/a', '/a'));
setScroll(viewportScroller, 10, 100);
events.next(new NavigationStart(2, '/b'));
events.next(new NavigationEnd(2, '/b', '/b'));
setScroll(viewportScroller, 20, 200);
events.next(new NavigationStart(3, '/c'));
events.next(new NavigationEnd(3, '/c', '/c'));
setScroll(viewportScroller, 30, 300);
events.next(new NavigationStart(4, '/a', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(4, '/a', '/a'));
tick(500);
expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled();
events.next(new NavigationStart(5, '/a', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(5, '/a', '/a'));
tick(5000);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100]);
}));
});
function createRouterScroller({scrollPositionRestoration, anchorScrolling}: {
scrollPositionRestoration: 'disabled' | 'enabled' | 'top',
anchorScrolling: 'disabled' | 'enabled'
}) {
const events = new Subject<RouterEvent>();
const router = <any>{
events,
parseUrl: (url: any) => new DefaultUrlSerializer().parse(url),
triggerEvent: (e: any) => events.next(e)
};
const viewportScroller = jasmine.createSpyObj(
'viewportScroller',
['getScrollPosition', 'scrollToPosition', 'scrollToAnchor', 'setHistoryScrollRestoration']);
setScroll(viewportScroller, 0, 0);
const scroller =
new RouterScroller(router, viewportScroller, {scrollPositionRestoration, anchorScrolling});
scroller.init();
return {events, viewportScroller, router};
}
function setScroll(viewportScroller: any, x: number, y: number) {
viewportScroller.getScrollPosition.and.returnValue([x, y]);
}
});

View File

@ -433,6 +433,15 @@ export declare class UpperCasePipe implements PipeTransform {
export declare const VERSION: Version; export declare const VERSION: Version;
export declare abstract class ViewportScroller {
abstract getScrollPosition(): [number, number];
abstract scrollToAnchor(anchor: string): void;
abstract scrollToPosition(position: [number, number]): void;
abstract setHistoryScrollRestoration(scrollRestoration: 'auto' | 'manual'): void;
abstract setOffset(offset: [number, number] | (() => [number, number])): void;
static ngInjectableDef: never;
}
/** @experimental */ /** @experimental */
export declare enum WeekDay { export declare enum WeekDay {
Sunday = 0, Sunday = 0,

View File

@ -108,15 +108,18 @@ export declare class DefaultUrlSerializer implements UrlSerializer {
/** @experimental */ /** @experimental */
export declare type DetachedRouteHandle = {}; export declare type DetachedRouteHandle = {};
export declare type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | ChildActivationEnd | ActivationStart | ActivationEnd; export declare type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | ChildActivationEnd | ActivationStart | ActivationEnd | Scroll;
export interface ExtraOptions { export interface ExtraOptions {
anchorScrolling?: 'disabled' | 'enabled';
enableTracing?: boolean; enableTracing?: boolean;
errorHandler?: ErrorHandler; errorHandler?: ErrorHandler;
initialNavigation?: InitialNavigation; initialNavigation?: InitialNavigation;
onSameUrlNavigation?: 'reload' | 'ignore'; onSameUrlNavigation?: 'reload' | 'ignore';
paramsInheritanceStrategy?: 'emptyOnly' | 'always'; paramsInheritanceStrategy?: 'emptyOnly' | 'always';
preloadingStrategy?: any; preloadingStrategy?: any;
scrollOffset?: [number, number] | (() => [number, number]);
scrollPositionRestoration?: 'disabled' | 'enabled' | 'top';
useHash?: boolean; useHash?: boolean;
} }
@ -465,6 +468,17 @@ export declare class RoutesRecognized extends RouterEvent {
/** @experimental */ /** @experimental */
export declare type RunGuardsAndResolvers = 'paramsChange' | 'paramsOrQueryParamsChange' | 'always'; export declare type RunGuardsAndResolvers = 'paramsChange' | 'paramsOrQueryParamsChange' | 'always';
export declare class Scroll {
readonly anchor: string | null;
readonly position: [number, number] | null;
readonly routerEvent: NavigationEnd;
constructor(
routerEvent: NavigationEnd,
position: [number, number] | null,
anchor: string | null);
toString(): string;
}
/** @experimental */ /** @experimental */
export declare abstract class UrlHandlingStrategy { export declare abstract class UrlHandlingStrategy {
abstract extract(url: UrlTree): UrlTree; abstract extract(url: UrlTree): UrlTree;