8000 feat(docs-infra): saves the scroll position before the change of loca… by wKoza · Pull Request #28037 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

feat(docs-infra): saves the scroll position before the change of loca… #28037

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions aio/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ describe('AppComponent', () => {

expect(component.tocMaxHeight).toMatch(/^\d+\.\d{2}$/);
});

it('should update `scrollService.updateScrollPositonInHistory()`', () => {
const scrollService = fixture.debugElement.injector.get<ScrollService>(ScrollService);
spyOn(scrollService, 'updateScrollPositionInHistory');
component.onScroll();
expect(scrollService.updateScrollPositionInHistory).toHaveBeenCalled();
});
});

describe('SideNav', () => {
Expand Down Expand Up @@ -461,11 +468,15 @@ describe('AppComponent', () => {
let scrollService: ScrollService;
let scrollSpy: jasmine.Spy;
let scrollToTopSpy: jasmine.Spy;
let scrollAfterRenderSpy: jasmine.Spy;
let removeStoredScrollPositionSpy: jasmine.Spy;

beforeEach(() => {
scrollService = fixture.debugElement.injector.get<ScrollService>(ScrollService);
scrollSpy = spyOn(scrollService, 'scroll');
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
scrollAfterRenderSpy = spyOn(scrollService, 'scrollAfterRender');
removeStoredScrollPositionSpy = spyOn(scrollService, 'removeStoredScrollPosition');
});

it('should not scroll immediately when the docId (path) changes', () => {
Expand Down Expand Up @@ -510,33 +521,24 @@ describe('AppComponent', () => {
expect(scrollSpy).toHaveBeenCalledTimes(1);
});

it('should scroll to top when call `onDocRemoved` directly', () => {
scrollToTopSpy.calls.reset();

it('should call `removeStoredScrollPosition` when call `onDocRemoved` directly', () => {
component.onDocRemoved();
expect(scrollToTopSpy).toHaveBeenCalled();
expect(removeStoredScrollPositionSpy).toHaveBeenCalled();
});

it('should scroll after a delay when call `onDocInserted` directly', fakeAsync(() => {
it('should call `scrollAfterRender` when call `onDocInserted` directly', (() => {
component.onDocInserted();
expect(scrollSpy).not.toHaveBeenCalled();

tick(scrollDelay);
expect(scrollSpy).toHaveBeenCalled();
expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay);
}));

it('should scroll (via `onDocInserted`) when finish navigating to a new doc', fakeAsync(() => {
expect(scrollToTopSpy).not.toHaveBeenCalled();

it('should call `scrollAfterRender` (via `onDocInserted`) when navigate to a new Doc', fakeAsync(() => {
locationService.go('guide/pipes');
tick(1); // triggers the HTTP response for the document
tick(1); // triggers the HTTP response for the document
fixture.detectChanges(); // triggers the event that calls `onDocInserted`

expect(scrollToTopSpy).toHaveBeenCalled();
expect(scrollSpy).not.toHaveBeenCalled();
expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay);

tick(scrollDelay);
expect(scrollSpy).toHaveBeenCalled();
tick(500); // there are other outstanding timers in the AppComponent that are not relevant
}));
});

Expand Down
20 changes: 7 additions & 13 deletions aio/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class AppComponent implements OnInit {
}
if (path === this.currentPath) {
// scroll only if on same page (most likely a change to the hash)
this.autoScroll();
this.scrollService.scroll();
} else {
// don't scroll; leave that to `onDocRendered`
this.currentPath = path;
Expand Down Expand Up @@ -187,11 +187,6 @@ export class AppComponent implements OnInit {
.subscribe(() => this.updateShell());
}

// Scroll to the anchor in the hash fragment or top of doc.
autoScroll() {
this.scrollService.scroll();
}

onDocReady() {
// About to transition to new view.
this.isTransitioning = true;
Expand All @@ -204,9 +199,7 @@ export class AppComponent implements OnInit {
}

onDocRemoved() {
// The previous document has been removed.
// Scroll to top to restore a clean visual state for the new document.
this.scrollService.scrollToTop();
this.scrollService.removeStoredScrollPosition();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove the comment from here? I think this is the right place for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the method is onDocRemoved. I think the first line is redundant.
The second line have to be removed. We stop to scrollToTop() when the Doc is removed.

}

onDocInserted() {
Expand All @@ -216,9 +209,8 @@ export class AppComponent implements OnInit {
// (e.g. sidenav, host classes) needs to happen asynchronously.
setTimeout(() => this.updateShell());

// Scroll 500ms after the new document has been inserted into the doc-viewer.
// The delay is to allow time for async layout to complete.
setTimeout(() => this.autoScroll(), 500);
// Scroll the good position depending on the context
this.scrollService.scrollAfterRender(500);
}

onDocRendered() {
Expand Down Expand Up @@ -256,7 +248,6 @@ export class AppComponent implements OnInit {

@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean {

// Hide the search results if we clicked outside both the "search box" and the "search results"
if (!this.searchElements.some(element => element.nativeElement.contains(eventTarget))) {
this.hideSearchResults();
Expand Down Expand Up @@ -348,6 +339,9 @@ export class AppComponent implements OnInit {
// Dynamically change height of table of contents container
@HostListener('window:scroll')
onScroll() {

this.scrollService.updateScrollPositionInHistory();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since updateScrollPositionInHistory() isn't trivial, it might be a good idea to do some debouncing here. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had asked me the question when I wanted to use the scroll event. For the moment, I did not feel any lag. But, it is an option.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Chrome (desktop), after some scrolling, I start to get the following warnings in the console:

Throttling history state changes to prevent the browser from hanging.

So, I believe it is a good idea to do some throttling/debouncing here.


if (!this.tocMaxHeightOffset) {
// Must wait until `mat-toolbar` is measurable.
const el = this.hostElement.nativeElement as Element;
Expand Down
168 changes: 160 additions & 8 deletions aio/src/app/shared/scroll.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { ReflectiveInjector } from '@angular/core';
import { PlatformLocation } from '@angular/common';
import { Location, LocationStrategy, PlatformLocation, ViewportScroller } from '@angular/common';
import { DOCUMENT } from '@angular/common';
import { MockLocationStrategy, SpyLocation } from '@angular/common/testing';
import { fakeAsync, tick } from '@angular/core/testing';

import { ScrollService, topMargin } from './scroll.service';

describe('ScrollService', () => {
const topOfPageElem = {} as Element;
let injector: ReflectiveInjector;
let document: MockDocument;
let location: MockPlatformLocation;
let platformLocation: MockPlatformLocation;
let scrollService: ScrollService;
let location: SpyLocation;

class MockPlatformLocation {
hash: string;
Expand All @@ -27,19 +30,36 @@ describe('ScrollService', () => {
scrollIntoView = jasmine.createSpy('Element scrollIntoView');
}

const viewportScrollerStub = jasmine.createSpyObj(
'viewportScroller',
['getScrollPosition', 'scrollToPosition']);


beforeEach(() => {
spyOn(window, 'scrollBy');
});

beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
ScrollService,
{ provide: Location, useClass: SpyLocation },
{ provide: DOCUMENT, useClass: MockDocument },
{ provide: PlatformLocation, useClass: MockPlatformLocation }
{ provide: PlatformLocation, useClass: MockPlatformLocation },
{ provide: ViewportScroller, useValue: viewportScrollerStub },
{ provide: LocationStrategy, useClass: MockLocationStrategy }
]);
location = injector.get(PlatformLocation);
platformLocation = injector.get(PlatformLocation);
document = injector.get(DOCUMENT);
scrollService = injector.get(ScrollService);
location = injector.get(Location);
});

it('should set `scrollRestoration` to `manual` if supported', () => {
if (scrollService.supportManualScrollRestoration) {
expect(window.history.scrollRestoration).toBe('manual');
} else {
expect(window.history.scrollRestoration).toBeUndefined();
}
});

describe('#topOffset', () => {
Expand Down Expand Up @@ -107,7 +127,7 @@ describe('ScrollService', () => {

describe('#scroll', () => {
it('should scroll to the top if there is no hash', () => {
location.hash = '';
platformLocation.hash = '';

const topOfPage = new MockElement();
document.getElementById.and
Expand All @@ -118,7 +138,7 @@ describe('ScrollService', () => {
});

it('should not scroll if the hash does not match an element id', () => {
location.hash = 'not-found';
platformLocation.hash = 'not-found';
document.getElementById.and.returnValue(null);

scrollService.scroll();
Expand All @@ -128,7 +148,7 @@ describe('ScrollService', () => {

it('should scroll to the element whose id matches the hash', () => {
const element = new MockElement();
location.hash = 'some-id';
platformLocation.hash = 'some-id';
document.getElementById.and.returnValue(element);

scrollService.scroll();
Expand All @@ -139,7 +159,7 @@ describe('ScrollService', () => {

it('should scroll to the element whose id matches the hash with encoded characters', () => {
const element = new MockElement();
location.hash = '%F0%9F%91%8D'; // 👍
platformLocation.hash = '%F0%9F%91%8D'; // 👍
document.getElementById.and.returnValue(element);

scrollService.scroll();
Expand Down Expand Up @@ -210,4 +230,136 @@ describe('ScrollService', () => {
});
});

describe('#isLocationWithHash', () => {
it('should return true when the location has a hash', () => {
platformLocation.hash = 'anchor';
expect(scrollService.isLocationWithHash()).toBe(true);
});

it('should return false when the location has no hash', () => {
platformLocation.hash = '';
expect(scrollService.isLocationWithHash()).toBe(false);
});
});

describe('#needToFixScrollPosition', async() => {
it('should return true when popState event was fired after a back navigation if the browser supports ' +
'scrollRestoration`. Otherwise, needToFixScrollPosition() returns false', () => {

if (scrollService.supportManualScrollRestoration) {
location.go('/initial-url1');
// We simulate a scroll down
location.replaceState('/initial-url1', 'hack', {scrollPosition: [2000, 0]});
location.go('/initial-url2');
location.back();

expect(scrollService.popStateFired).toBe(true);
expect(scrollService.scrollPosition).toEqual([2000, 0]);
expect(scrollService.needToFixScrollPosition()).toBe(true);
} else {
location.go('/initial-url1');
location.go('/initial-url2');
location.back();

expect(scrollService.popStateFired).toBe(false); // popStateFired is always false
expect(scrollService.scrollPosition).toEqual([0, 0]); // scrollPosition always equals [0, 0]
expect(scrollService.needToFixScrollPosition()).toBe(false);
}

});

it('should return true when popState event was fired after a forward navigation if the browser supports ' +
'scrollRestoration`. Otherwise, needToFixScrollPosition() returns false', () => {

if (scrollService.supportManualScrollRestoration) {
location.go('/initial-url1');
location.go('/initial-url2');
// We simulate a scroll down
location.replaceState('/initial-url1', 'hack', {scrollPosition: [2000, 0]});

location.back();
scrollService.popStateFired = false;
scrollService.scrollPosition = [0, 0];
location.forward();

expect(scrollService.popStateFired).toBe(true);
expect(scrollService.scrollPosition).toEqual([2000, 0]);
expect(scrollService.needToFixScrollPosition()).toBe(true);
} else {
location.go('/initial-url1');
location.go('/initial-url2');
location.back();
location.forward();

expect(scrollService.popStateFired).toBe(false); // popStateFired is always false
expect(scrollService.scrollPosition).toEqual([0, 0]); // scrollPosition always equals [0, 0]
expect(scrollService.needToFixScrollPosition()).toBe(false);
}

});
});

describe('#scrollAfterRender', async() => {

let scrollSpy: jasmine.Spy;
let scrollToTopSpy: jasmine.Spy;
let needToFixScrollPositionSpy: jasmine.Spy;
let scrollToPosition: jasmine.Spy;
let isLocationWithHashSpy: jasmine.Spy;
let getStoredScrollPositionSpy: jasmine.Spy;
const scrollDelay = 500;

beforeEach(() => {
scrollSpy = spyOn(scrollService, 'scroll');
scrollToTopSpy = spyOn(scrollService, 'scrollToTop');
scrollToPosition = spyOn(scrollService, 'scrollToPosition');
needToFixScrollPositionSpy = spyOn(scrollService, 'needToFixScrollPosition');
getStoredScrollPositionSpy = spyOn(scrollService, 'getStoredScrollPosition');
isLocationWithHashSpy = spyOn(scrollService, 'isLocationWithHash');
});


it('should call `scroll` when we navigate to a location with anchor', fakeAsync(() => {
needToFixScrollPositionSpy.and.returnValue(false);
getStoredScrollPositionSpy.and.returnValue(null);
isLocationWithHashSpy.and.returnValue(true);

scrollService.scrollAfterRender(scrollDelay);

expect(scrollSpy).not.toHaveBeenCalled();
tick(scrollDelay);
expect(scrollSpy).toHaveBeenCalled();
}));

it('should call `scrollToTop` when we navigate to a location without anchor', fakeAsync(() => {
needToFixScrollPositionSpy.and.returnValue(false);
getStoredScrollPositionSpy.and.returnValue(null);
isLocationWithHashSpy.and.returnValue(false);

scrollService.scrollAfterRender(scrollDelay);

expect(scrollToTopSpy).toHaveBeenCalled();
tick(scrollDelay);
expect(scrollSpy).not.toHaveBeenCalled();
}));

it('should call `viewportScroller.scrollToPosition` when we reload a page', fakeAsync(() => {
getStoredScrollPositionSpy.and.returnValue([0, 1000]);

scrollService.scrollAfterRender(scrollDelay);

expect(viewportScrollerStub.scrollToPosition).toHaveBeenCalled();
expect(getStoredScrollPositionSpy).toHaveBeenCalled();
}));

it('should call `scrollToPosition` after a popState', fakeAsync(() => {
needToFixScrollPositionSpy.and.returnValue(true);
getStoredScrollPositionSpy.and.returnValue(null);
scrollService.scrollAfterRender(scrollDelay);
expect(scrollToPosition).toHaveBeenCalled();
tick(scrollDelay);
expect(scrollSpy).not.toHaveBeenCalled();
expect(scrollToTopSpy).not.toHaveBeenCalled();
}));
});
});
Loading
0