Skip to content

Commit 49c5234

Browse files
vsavkinmhevery
authored andcommitted
feat(router): implement scrolling restoration service (#20030)
For documentation, see `RouterModule.scrollPositionRestoration` Fixes #13636 #10929 #7791 #6595 PR Close #20030
1 parent 1b253e1 commit 49c5234

File tree

13 files changed

+662
-11
lines changed

13 files changed

+662
-11
lines changed

Diff for: packages/common/src/common.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCase
2525
export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index';
2626
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';
2727
export {VERSION} from './version';
28+
export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller';

Diff for: packages/common/src/viewport_scroller.ts

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {defineInjectable, inject} from '@angular/core';
10+
11+
import {DOCUMENT} from './dom_tokens';
12+
13+
/**
14+
* @whatItDoes Manages the scroll position.
15+
*/
16+
export abstract class ViewportScroller {
17+
// De-sugared tree-shakable injection
18+
// See #23917
19+
/** @nocollapse */
20+
static ngInjectableDef = defineInjectable(
21+
{providedIn: 'root', factory: () => new BrowserViewportScroller(inject(DOCUMENT), window)});
22+
23+
/**
24+
* @whatItDoes Configures the top offset used when scrolling to an anchor.
25+
*
26+
* When given a tuple with two number, the service will always use the numbers.
27+
* When given a function, the service will invoke the function every time it restores scroll
28+
* position.
29+
*/
30+
abstract setOffset(offset: [number, number]|(() => [number, number])): void;
31+
32+
/**
33+
* @whatItDoes Returns the current scroll position.
34+
*/
35+
abstract getScrollPosition(): [number, number];
36+
37+
/**
38+
* @whatItDoes Sets the scroll position.
39+
*/
40+
abstract scrollToPosition(position: [number, number]): void;
41+
42+
/**
43+
* @whatItDoes Scrolls to the provided anchor.
44+
*/
45+
abstract scrollToAnchor(anchor: string): void;
46+
47+
/**
48+
* @whatItDoes Disables automatic scroll restoration provided by the browser.
49+
* See also [window.history.scrollRestoration
50+
* info](https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration)
51+
*/
52+
abstract setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void;
53+
}
54+
55+
/**
56+
* @whatItDoes Manages the scroll position.
57+
*/
58+
export class BrowserViewportScroller implements ViewportScroller {
59+
private offset: () => [number, number] = () => [0, 0];
60+
61+
constructor(private document: any, private window: any) {}
62+
63+
/**
64+
* @whatItDoes Configures the top offset used when scrolling to an anchor.
65+
*
66+
* * When given a number, the service will always use the number.
67+
* * When given a function, the service will invoke the function every time it restores scroll
68+
* position.
69+
*/
70+
setOffset(offset: [number, number]|(() => [number, number])): void {
71+
if (Array.isArray(offset)) {
72+
this.offset = () => offset;
73+
} else {
74+
this.offset = offset;
75+
}
76+
}
77+
78+
/**
79+
* @whatItDoes Returns the current scroll position.
80+
*/
81+
getScrollPosition(): [number, number] {
82+
if (this.supportScrollRestoration()) {
83+
return [this.window.scrollX, this.window.scrollY];
84+
} else {
85+
return [0, 0];
86+
}
87+
}
88+
89+
/**
90+
* @whatItDoes Sets the scroll position.
91+
*/
92+
scrollToPosition(position: [number, number]): void {
93+
if (this.supportScrollRestoration()) {
94+
this.window.scrollTo(position[0], position[1]);
95+
}
96+
}
97+
98+
/**
99+
* @whatItDoes Scrolls to the provided anchor.
100+
*/
101+
scrollToAnchor(anchor: string): void {
102+
if (this.supportScrollRestoration()) {
103+
const elSelectedById = this.document.querySelector(`#${anchor}`);
104+
if (elSelectedById) {
105+
this.scrollToElement(elSelectedById);
106+
return;
107+
}
108+
const elSelectedByName = this.document.querySelector(`[name='${anchor}']`);
109+
if (elSelectedByName) {
110+
this.scrollToElement(elSelectedByName);
111+
return;
112+
}
113+
}
114+
}
115+
116+
/**
117+
* @whatItDoes Disables automatic scroll restoration provided by the browser.
118+
*/
119+
setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {
120+
if (this.supportScrollRestoration()) {
121+
const history = this.window.history;
122+
if (history && history.scrollRestoration) {
123+
history.scrollRestoration = scrollRestoration;
124+
}
125+
}
126+
}
127+
128+
private scrollToElement(el: any): void {
129+
const rect = el.getBoundingClientRect();
130+
const left = rect.left + this.window.pageXOffset;
131+
const top = rect.top + this.window.pageYOffset;
132+
const offset = this.offset();
133+
this.window.scrollTo(left - offset[0], top - offset[1]);
134+
}
135+
136+
/**
137+
* We only support scroll restoration when we can get a hold of window.
138+
* This means that we do not support this behavior when running in a web worker.
139+
*
140+
* Lifting this restriction right now would require more changes in the dom adapter.
141+
* Since webworkers aren't widely used, we will lift it once RouterScroller is
142+
* battle-tested.
143+
*/
144+
private supportScrollRestoration(): boolean {
145+
try {
146+
return !!this.window && !!this.window.scrollTo;
147+
} catch (e) {
148+
return false;
149+
}
150+
}
151+
}
152+
153+
154+
/**
155+
* @whatItDoes Provides an empty implementation of the viewport scroller. This will
156+
* live in @angular/common as it will be used by both platform-server and platform-webworker.
157+
*/
158+
export class NullViewportScroller implements ViewportScroller {
159+
/**
160+
* @whatItDoes empty implementation
161+
*/
162+
setOffset(offset: [number, number]|(() => [number, number])): void {}
163+
164+
/**
165+
* @whatItDoes empty implementation
166+
*/
167+
getScrollPosition(): [number, number] { return [0, 0]; }
168+
169+
/**
170+
* @whatItDoes empty implementation
171+
*/
172+
scrollToPosition(position: [number, number]): void {}
173+
174+
/**
175+
* @whatItDoes empty implementation
176+
*/
177+
scrollToAnchor(anchor: string): void {}
178+
179+
/**
180+
* @whatItDoes empty implementation
181+
*/
182+
setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {}
183+
}

Diff for: packages/platform-server/src/server.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {ɵAnimationEngine} from '@angular/animations/browser';
10-
import {PlatformLocation, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common';
10+
import {PlatformLocation, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common';
1111
import {HttpClientModule} from '@angular/common/http';
1212
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';
1313
import {HttpModule} from '@angular/http';
@@ -74,6 +74,7 @@ export const SERVER_RENDER_PROVIDERS: Provider[] = [
7474
SERVER_RENDER_PROVIDERS,
7575
SERVER_HTTP_PROVIDERS,
7676
{provide: Testability, useValue: null},
77+
{provide: ViewportScroller, useClass: NullViewportScroller},
7778
],
7879
})
7980
export class ServerModule {

Diff for: packages/platform-webworker/src/worker_app.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {CommonModule, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common';
9+
import {CommonModule, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common';
1010
import {APP_INITIALIZER, ApplicationModule, ErrorHandler, NgModule, NgZone, PLATFORM_ID, PlatformRef, RendererFactory2, RootRenderer, StaticProvider, createPlatformFactory, platformCore} from '@angular/core';
1111
import {DOCUMENT, ɵBROWSER_SANITIZATION_PROVIDERS as BROWSER_SANITIZATION_PROVIDERS} from '@angular/platform-browser';
1212

@@ -71,6 +71,7 @@ export function setupWebWorker(): void {
7171
{provide: ErrorHandler, useFactory: errorHandler, deps: []},
7272
{provide: MessageBus, useFactory: createMessageBus, deps: [NgZone]},
7373
{provide: APP_INITIALIZER, useValue: setupWebWorker, multi: true},
74+
{provide: ViewportScroller, useClass: NullViewportScroller, deps: []},
7475
],
7576
exports: [
7677
CommonModule,

Diff for: packages/router/src/events.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,28 @@ export class ActivationEnd {
401401
}
402402
}
403403

404+
/**
405+
* @description
406+
*
407+
* Represents a scrolling event.
408+
*/
409+
export class Scroll {
410+
constructor(
411+
/** @docsNotRequired */
412+
readonly routerEvent: NavigationEnd,
413+
414+
/** @docsNotRequired */
415+
readonly position: [number, number]|null,
416+
417+
/** @docsNotRequired */
418+
readonly anchor: string|null) {}
419+
420+
toString(): string {
421+
const pos = this.position ? `${this.position[0]}, ${this.position[1]}` : null;
422+
return `Scroll(anchor: '${this.anchor}', position: '${pos}')`;
423+
}
424+
}
425+
404426
/**
405427
* @description
406428
*
@@ -423,8 +445,9 @@ export class ActivationEnd {
423445
* - `NavigationEnd`,
424446
* - `NavigationCancel`,
425447
* - `NavigationError`
448+
* - `Scroll`
426449
*
427450
*
428451
*/
429452
export type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart |
430-
ChildActivationEnd | ActivationStart | ActivationEnd;
453+
ChildActivationEnd | ActivationStart | ActivationEnd | Scroll;

Diff for: packages/router/src/index.ts

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

Diff for: packages/router/src/router.ts

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

0 commit comments

Comments
 (0)