fix(docs-infra): scroll to top when navigating to new page via address bar (#33344)
Previously, when navigating to a new page via a link, the scroll position was correctly restored to 0, but navigating to a new page via typing the URL in the browser address bar keeps the old scroll position. This commit ensures that the scroll position is restored to 0 whenever the `ScrollService` is instantiated anew (i.e. new page navigation). The old behavior of retaining the scroll position on reload is kept by storing the old URL when leaving a page and only applying the stored scroll position if the new URL matches the stored one. Fixes #33260 PR Close #33344
This commit is contained in:
parent
ed4d96f858
commit
43ac02e566
@ -464,14 +464,14 @@ describe('AppComponent', () => {
|
|||||||
let scrollSpy: jasmine.Spy;
|
let scrollSpy: jasmine.Spy;
|
||||||
let scrollToTopSpy: jasmine.Spy;
|
let scrollToTopSpy: jasmine.Spy;
|
||||||
let scrollAfterRenderSpy: jasmine.Spy;
|
let scrollAfterRenderSpy: jasmine.Spy;
|
||||||
let removeStoredScrollPositionSpy: jasmine.Spy;
|
let removeStoredScrollInfoSpy: jasmine.Spy;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
scrollService = fixture.debugElement.injector.get<ScrollService>(ScrollService);
|
scrollService = fixture.debugElement.injector.get<ScrollService>(ScrollService);
|
||||||
scrollSpy = spyOn(scrollService, 'scroll');
|
scrollSpy = spyOn(scrollService, 'scroll');
|
||||||
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
|
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
|
||||||
scrollAfterRenderSpy = spyOn(scrollService, 'scrollAfterRender');
|
scrollAfterRenderSpy = spyOn(scrollService, 'scrollAfterRender');
|
||||||
removeStoredScrollPositionSpy = spyOn(scrollService, 'removeStoredScrollPosition');
|
removeStoredScrollInfoSpy = spyOn(scrollService, 'removeStoredScrollInfo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not scroll immediately when the docId (path) changes', () => {
|
it('should not scroll immediately when the docId (path) changes', () => {
|
||||||
@ -516,9 +516,9 @@ describe('AppComponent', () => {
|
|||||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call `removeStoredScrollPosition` when call `onDocRemoved` directly', () => {
|
it('should call `removeStoredScrollInfo` when call `onDocRemoved` directly', () => {
|
||||||
component.onDocRemoved();
|
component.onDocRemoved();
|
||||||
expect(removeStoredScrollPositionSpy).toHaveBeenCalled();
|
expect(removeStoredScrollInfoSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call `scrollAfterRender` when call `onDocInserted` directly', (() => {
|
it('should call `scrollAfterRender` when call `onDocInserted` directly', (() => {
|
||||||
|
@ -202,7 +202,7 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDocRemoved() {
|
onDocRemoved() {
|
||||||
this.scrollService.removeStoredScrollPosition();
|
this.scrollService.removeStoredScrollInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
onDocInserted() {
|
onDocInserted() {
|
||||||
|
@ -296,11 +296,11 @@ describe('LocationService', () => {
|
|||||||
it('should do a "full page navigation" and remove the stored scroll position when navigating to ' +
|
it('should do a "full page navigation" and remove the stored scroll position when navigating to ' +
|
||||||
'internal URLs only if a ServiceWorker update has been activated', () => {
|
'internal URLs only if a ServiceWorker update has been activated', () => {
|
||||||
const goExternalSpy = spyOn(service, 'goExternal');
|
const goExternalSpy = spyOn(service, 'goExternal');
|
||||||
const removeStoredScrollPositionSpy = spyOn(scrollService, 'removeStoredScrollPosition');
|
const removeStoredScrollInfoSpy = spyOn(scrollService, 'removeStoredScrollInfo');
|
||||||
|
|
||||||
// Internal URL - No ServiceWorker update
|
// Internal URL - No ServiceWorker update
|
||||||
service.go('some-internal-url');
|
service.go('some-internal-url');
|
||||||
expect(removeStoredScrollPositionSpy).not.toHaveBeenCalled();
|
expect(removeStoredScrollInfoSpy).not.toHaveBeenCalled();
|
||||||
expect(goExternalSpy).not.toHaveBeenCalled();
|
expect(goExternalSpy).not.toHaveBeenCalled();
|
||||||
expect(location.path(true)).toEqual('some-internal-url');
|
expect(location.path(true)).toEqual('some-internal-url');
|
||||||
|
|
||||||
@ -308,24 +308,24 @@ describe('LocationService', () => {
|
|||||||
swUpdates.updateActivated.next('foo');
|
swUpdates.updateActivated.next('foo');
|
||||||
service.go('other-internal-url');
|
service.go('other-internal-url');
|
||||||
expect(goExternalSpy).toHaveBeenCalledWith('other-internal-url');
|
expect(goExternalSpy).toHaveBeenCalledWith('other-internal-url');
|
||||||
expect(removeStoredScrollPositionSpy).toHaveBeenCalled();
|
expect(removeStoredScrollInfoSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not remove the stored scroll position when navigating to external URLs', () => {
|
it('should not remove the stored scroll position when navigating to external URLs', () => {
|
||||||
const removeStoredScrollPositionSpy = spyOn(scrollService, 'removeStoredScrollPosition');
|
const removeStoredScrollInfoSpy = spyOn(scrollService, 'removeStoredScrollInfo');
|
||||||
const goExternalSpy = spyOn(service, 'goExternal');
|
const goExternalSpy = spyOn(service, 'goExternal');
|
||||||
const externalUrl = 'http://some/far/away/land';
|
const externalUrl = 'http://some/far/away/land';
|
||||||
const otherExternalUrl = 'http://some/far/far/away/land';
|
const otherExternalUrl = 'http://some/far/far/away/land';
|
||||||
|
|
||||||
// External URL - No ServiceWorker update
|
// External URL - No ServiceWorker update
|
||||||
service.go(externalUrl);
|
service.go(externalUrl);
|
||||||
expect(removeStoredScrollPositionSpy).not.toHaveBeenCalled();
|
expect(removeStoredScrollInfoSpy).not.toHaveBeenCalled();
|
||||||
expect(goExternalSpy).toHaveBeenCalledWith(externalUrl);
|
expect(goExternalSpy).toHaveBeenCalledWith(externalUrl);
|
||||||
|
|
||||||
// External URL - ServiceWorker update
|
// External URL - ServiceWorker update
|
||||||
swUpdates.updateActivated.next('foo');
|
swUpdates.updateActivated.next('foo');
|
||||||
service.go(otherExternalUrl);
|
service.go(otherExternalUrl);
|
||||||
expect(removeStoredScrollPositionSpy).not.toHaveBeenCalled();
|
expect(removeStoredScrollInfoSpy).not.toHaveBeenCalled();
|
||||||
expect(goExternalSpy).toHaveBeenCalledWith(otherExternalUrl);
|
expect(goExternalSpy).toHaveBeenCalledWith(otherExternalUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -633,7 +633,7 @@ class MockSwUpdatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MockScrollService {
|
class MockScrollService {
|
||||||
removeStoredScrollPosition() { }
|
removeStoredScrollInfo() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestGaService {
|
class TestGaService {
|
||||||
|
@ -49,7 +49,7 @@ export class LocationService {
|
|||||||
} else if (this.swUpdateActivated) {
|
} else if (this.swUpdateActivated) {
|
||||||
// (Do a "full page navigation" if a ServiceWorker update has been activated)
|
// (Do a "full page navigation" if a ServiceWorker update has been activated)
|
||||||
// We need to remove stored Position in order to be sure to scroll to the Top position
|
// We need to remove stored Position in order to be sure to scroll to the Top position
|
||||||
this.scrollService.removeStoredScrollPosition();
|
this.scrollService.removeStoredScrollInfo();
|
||||||
this.goExternal(url);
|
this.goExternal(url);
|
||||||
} else {
|
} else {
|
||||||
this.location.go(url);
|
this.location.go(url);
|
||||||
|
@ -58,6 +58,10 @@ export class ScrollService implements OnDestroy {
|
|||||||
.pipe(debounceTime(250), takeUntil(this.onDestroy))
|
.pipe(debounceTime(250), takeUntil(this.onDestroy))
|
||||||
.subscribe(() => this.updateScrollPositionInHistory());
|
.subscribe(() => this.updateScrollPositionInHistory());
|
||||||
|
|
||||||
|
fromEvent(window, 'beforeunload')
|
||||||
|
.pipe(takeUntil(this.onDestroy))
|
||||||
|
.subscribe(() => this.updateScrollLocationHref());
|
||||||
|
|
||||||
// Change scroll restoration strategy to `manual` if it's supported
|
// Change scroll restoration strategy to `manual` if it's supported
|
||||||
if (this.supportManualScrollRestoration) {
|
if (this.supportManualScrollRestoration) {
|
||||||
history.scrollRestoration = 'manual';
|
history.scrollRestoration = 'manual';
|
||||||
@ -70,13 +74,18 @@ export class ScrollService implements OnDestroy {
|
|||||||
} else {
|
} else {
|
||||||
// Navigating with the forward/back button, we have to remove the position from the
|
// Navigating with the forward/back button, we have to remove the position from the
|
||||||
// session storage in order to avoid a race-condition.
|
// session storage in order to avoid a race-condition.
|
||||||
this.removeStoredScrollPosition();
|
this.removeStoredScrollInfo();
|
||||||
// The `popstate` event is always triggered by a browser action such as clicking the
|
// The `popstate` event is always triggered by a browser action such as clicking the
|
||||||
// forward/back button. It can be followed by a `hashchange` event.
|
// forward/back button. It can be followed by a `hashchange` event.
|
||||||
this.poppedStateScrollPosition = event.state ? event.state.scrollPosition : null;
|
this.poppedStateScrollPosition = event.state ? event.state.scrollPosition : null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this was not a reload, discard the stored scroll info.
|
||||||
|
if (window.location.href !== this.getStoredScrollLocationHref()) {
|
||||||
|
this.removeStoredScrollInfo();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@ -170,6 +179,10 @@ export class ScrollService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateScrollLocationHref(): void {
|
||||||
|
window.sessionStorage.setItem('scrollLocationHref', window.location.href);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the state with scroll position into history.
|
* Update the state with scroll position into history.
|
||||||
*/
|
*/
|
||||||
@ -181,6 +194,11 @@ export class ScrollService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStoredScrollLocationHref(): string | null {
|
||||||
|
const href = window.sessionStorage.getItem('scrollLocationHref');
|
||||||
|
return href || null;
|
||||||
|
}
|
||||||
|
|
||||||
getStoredScrollPosition(): ScrollPosition | null {
|
getStoredScrollPosition(): ScrollPosition | null {
|
||||||
const position = window.sessionStorage.getItem('scrollPosition');
|
const position = window.sessionStorage.getItem('scrollPosition');
|
||||||
if (!position) { return null; }
|
if (!position) { return null; }
|
||||||
@ -189,7 +207,8 @@ export class ScrollService implements OnDestroy {
|
|||||||
return [+x, +y];
|
return [+x, +y];
|
||||||
}
|
}
|
||||||
|
|
||||||
removeStoredScrollPosition() {
|
removeStoredScrollInfo() {
|
||||||
|
window.sessionStorage.removeItem('scrollLocationHref');
|
||||||
window.sessionStorage.removeItem('scrollPosition');
|
window.sessionStorage.removeItem('scrollPosition');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,7 +100,7 @@ describe('site App', function() {
|
|||||||
it('should scroll to the top when navigating to another page', () => {
|
it('should scroll to the top when navigating to another page', () => {
|
||||||
page.navigateTo('guide/security');
|
page.navigateTo('guide/security');
|
||||||
|
|
||||||
page.scrollToBottom();
|
page.scrollTo('bottom');
|
||||||
expect(page.getScrollTop()).toBeGreaterThan(0);
|
expect(page.getScrollTop()).toBeGreaterThan(0);
|
||||||
|
|
||||||
page.click(page.getNavItem(/api/i));
|
page.click(page.getNavItem(/api/i));
|
||||||
@ -111,7 +111,7 @@ describe('site App', function() {
|
|||||||
it('should scroll to the top when navigating to the same page', () => {
|
it('should scroll to the top when navigating to the same page', () => {
|
||||||
page.navigateTo('guide/security');
|
page.navigateTo('guide/security');
|
||||||
|
|
||||||
page.scrollToBottom();
|
page.scrollTo('bottom');
|
||||||
expect(page.getScrollTop()).toBeGreaterThan(0);
|
expect(page.getScrollTop()).toBeGreaterThan(0);
|
||||||
|
|
||||||
page.click(page.getNavItem(/security/i));
|
page.click(page.getNavItem(/security/i));
|
||||||
|
@ -59,8 +59,13 @@ export class SitePage {
|
|||||||
return browser.executeScript('return window.pageYOffset');
|
return browser.executeScript('return window.pageYOffset');
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom() {
|
scrollTo(y: 'top' | 'bottom' | number) {
|
||||||
return browser.executeScript('window.scrollTo(0, document.body.scrollHeight)');
|
const yExpr = (y === 'top') ? '0' : (y === 'bottom') ? 'document.body.scrollHeight' : y;
|
||||||
|
|
||||||
|
return browser.executeScript(`
|
||||||
|
window.scrollTo(0, ${yExpr});
|
||||||
|
window.dispatchEvent(new Event('scroll'));
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
click(elementFinder: ElementFinder) {
|
click(elementFinder: ElementFinder) {
|
||||||
|
90
aio/tests/e2e/src/scroll.e2e-spec.ts
Normal file
90
aio/tests/e2e/src/scroll.e2e-spec.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { browser } from 'protractor';
|
||||||
|
import { SitePage } from './app.po';
|
||||||
|
|
||||||
|
describe('site auto-scrolling', () => {
|
||||||
|
let page: SitePage;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const scrollAndWait = async (y: Parameters<SitePage['scrollTo']>[0] = 'bottom') => {
|
||||||
|
await page.scrollTo(y);
|
||||||
|
await browser.sleep(500); // Scroll position is stored every 250ms for performance reasons.
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
page = new SitePage();
|
||||||
|
await page.navigateTo('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be initially scrolled to top', async () => {
|
||||||
|
expect(await page.getScrollTop()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to top when navigating to a different page', async () => {
|
||||||
|
await scrollAndWait();
|
||||||
|
expect(await page.getScrollTop()).not.toBe(0);
|
||||||
|
|
||||||
|
await page.navigateTo('docs');
|
||||||
|
expect(await page.getScrollTop()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retain the scroll position on reload', async () => {
|
||||||
|
await scrollAndWait();
|
||||||
|
expect(await page.getScrollTop()).not.toBe(0);
|
||||||
|
|
||||||
|
await browser.refresh();
|
||||||
|
expect(await page.getScrollTop()).not.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to top when navigating to a different page via a link', async () => {
|
||||||
|
await scrollAndWait();
|
||||||
|
expect(await page.getScrollTop()).not.toBe(0);
|
||||||
|
|
||||||
|
await page.docsMenuLink.click();
|
||||||
|
expect(await page.getScrollTop()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to top when navigating to the same page via a link', async () => {
|
||||||
|
await scrollAndWait();
|
||||||
|
expect(await page.getScrollTop()).not.toBe(0);
|
||||||
|
|
||||||
|
await page.homeLink.click();
|
||||||
|
expect(await page.getScrollTop()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Find a way to accurately emulate clicking the browser back/forward button. Apparently,
|
||||||
|
// both `browser.navigate().back()` and `browser.executeScript('history.back()')` cause a full
|
||||||
|
// page load, which behaves differently than clicking the browser back button (and triggering a
|
||||||
|
// `popstate` event instead of a navigation). Same for `forward()`.
|
||||||
|
xit('should retain the scroll position when navigating back/forward', async () => {
|
||||||
|
await scrollAndWait(100);
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(100, -1);
|
||||||
|
|
||||||
|
await page.navigateTo('docs');
|
||||||
|
await scrollAndWait(50);
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(50, -1);
|
||||||
|
|
||||||
|
await page.navigateTo('features');
|
||||||
|
await scrollAndWait(75);
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(75, -1);
|
||||||
|
|
||||||
|
// Go back.
|
||||||
|
await browser.navigate().back();
|
||||||
|
expect(await page.locationPath()).toBe('/docs');
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(50, -1);
|
||||||
|
|
||||||
|
// Go back.
|
||||||
|
await browser.navigate().back();
|
||||||
|
expect(await page.locationPath()).toBe('/');
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(100, -1);
|
||||||
|
|
||||||
|
// Go forward.
|
||||||
|
await browser.navigate().forward();
|
||||||
|
expect(await page.locationPath()).toBe('/docs');
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(50, -1);
|
||||||
|
|
||||||
|
// Go forward.
|
||||||
|
await browser.navigate().forward();
|
||||||
|
expect(await page.locationPath()).toBe('/features');
|
||||||
|
expect(await page.getScrollTop()).toBeCloseTo(75, -1);
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user