Skip to content

Commit 5b73f0a

Browse files
authored
Recursively check document height before scrollTo (#217)
1 parent e8fc2ba commit 5b73f0a

File tree

7 files changed

+194
-50
lines changed

7 files changed

+194
-50
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ module.exports = {
3333
'addon/**',
3434
'addon-test-support/**',
3535
'app/**',
36-
'tests/dummy/app/**'
36+
'tests/dummy/app/**',
37+
'tests/helpers/**'
3738
],
3839
parserOptions: {
3940
sourceType: 'script',

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ needs:[ 'service:router-scroll', 'service:scheduler' ],
7373

7474
### Options
7575

76+
#### Target Elements
77+
7678
If you need to scroll to the top of an area that generates a vertical scroll bar, you can specify the id of an element
7779
of the scrollable area. Default is `window` for using the scroll position of the whole viewport. You can pass an options
7880
object in your application's `config/environment.js` file.
@@ -93,15 +95,31 @@ ENV['routerScroll'] = {
9395
};
9496
```
9597

96-
Moreover, if your route breaks up render into multiple phases, you may need to delay scrollTop functionality until after
97-
the First Meaningful Paint using `delayScrollTop: true` in your config. `delayScrollTop` defaults to `false`.
98+
#### Scroll Timing
99+
100+
You may want the default "out of the box" behaviour. We schedule scroll after Ember's `render`. This occurs on the tightest schedule between route transition start and end
101+
102+
However, you have other options. You may need to delay scroll functionality until after
103+
the First Meaningful Paint using `scrollWhenPainted: true` in your config. `scrollWhenPainted` defaults to `false`.
104+
105+
Then next two config properties uses [`ember-app-scheduler`](https://github.com/ember-app-scheduler/ember-app-scheduler), so be sure to follow the instructions in the README. We include the `setupRouter` and `reset`. This all happens after `routeDidChange`.
106+
107+
```javascript
108+
ENV['routerScroll'] = {
109+
scrollWhenPainted: true
110+
};
111+
```
112+
113+
Also, if you need to perform the logic when the route is idle or if your route breaks up render into multiple phases, add `delayScrollTop: true` in your config. `delayScrollTop` defaults to `false`. This will be renamed to `scrollWhenIdle` in a major release.
98114

99115
```javascript
100116
ENV['routerScroll'] = {
101117
delayScrollTop: true
102118
};
103119
```
104120

121+
I would suggest trying all of them out and seeing which works best for your app!
122+
105123

106124
## A working example
107125

addon/index.js

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,45 @@ import { get, getWithDefault, computed } from '@ember/object';
33
import { inject } from '@ember/service';
44
import { getOwner } from '@ember/application';
55
import { scheduleOnce } from '@ember/runloop';
6-
import { setupRouter, reset, whenRouteIdle } from 'ember-app-scheduler';
6+
import { setupRouter, reset, whenRouteIdle, whenRoutePainted } from 'ember-app-scheduler';
77
import { gte } from 'ember-compatibility-helpers';
8+
import { getScrollBarWidth } from './utils/scrollbar-width';
9+
10+
let scrollBarWidth = getScrollBarWidth();
11+
const body = document.body;
12+
const html = document.documentElement;
13+
let ATTEMPTS = 0;
14+
const MAX_ATTEMPTS = 100; // rAF runs every 16ms ideally, so 60x a second
15+
let requestId;
16+
17+
/**
18+
* By default, we start checking to see if the document height is >= the last known `y` position
19+
* we want to scroll to. This is important for content heavy pages that might try to scrollTo
20+
* before the content has painted
21+
*
22+
* @method tryScrollRecursively
23+
* @param {Function} fn
24+
* @param {Object} scrollHash
25+
* @void
26+
*/
27+
function tryScrollRecursively(fn, scrollHash) {
28+
requestId = window.requestAnimationFrame(() => {
29+
const documentWidth = Math.max(body.scrollWidth, body.offsetWidth,
30+
html.clientWidth, html.scrollWidth, html.offsetWidth);
31+
const documentHeight = Math.max(body.scrollHeight, body.offsetHeight,
32+
html.clientHeight, html.scrollHeight, html.offsetHeight);
33+
34+
if (documentWidth + scrollBarWidth - window.innerWidth >= scrollHash.x
35+
&& documentHeight + scrollBarWidth - window.innerHeight >= scrollHash.y
36+
|| ATTEMPTS >= MAX_ATTEMPTS) {
37+
ATTEMPTS = 0;
38+
fn.call(null, scrollHash.x, scrollHash.y);
39+
} else {
40+
ATTEMPTS++;
41+
tryScrollRecursively(fn, scrollHash)
42+
}
43+
})
44+
}
845

946
let RouterScrollMixin = Mixin.create({
1047
service: inject('router-scroll'),
@@ -33,15 +70,21 @@ let RouterScrollMixin = Mixin.create({
3370
destroy() {
3471
reset();
3572

73+
if (requestId) {
74+
window.cancelAnimationFrame(requestId);
75+
}
76+
3677
this._super(...arguments);
3778
},
3879

3980
/**
4081
* Updates the scroll position
41-
* @param {transition|transition[]} transition If before Ember 3.6, this will be an array of transitions, otherwise
4282
* it will be a single transition
83+
* @method updateScrollPosition
84+
* @param {transition|transition[]} transition If before Ember 3.6, this will be an array of transitions, otherwise
85+
* @param {Boolean} recursiveCheck - if "true", check until document height is >= y. `y` is the last coordinate the target page was on
4386
*/
44-
updateScrollPosition(transition) {
87+
updateScrollPosition(transition, recursiveCheck) {
4588
const url = get(this, 'currentURL');
4689
const hashElement = url ? document.getElementById(url.split('#').pop()) : null;
4790

@@ -74,10 +117,14 @@ let RouterScrollMixin = Mixin.create({
74117
const scrollElement = get(this, 'service.scrollElement');
75118
const targetElement = get(this, 'service.targetElement');
76119

77-
if (targetElement) {
78-
window.scrollTo(scrollPosition.x, scrollPosition.y);
79-
} else if ('window' === scrollElement) {
80-
window.scrollTo(scrollPosition.x, scrollPosition.y);
120+
if (targetElement || 'window' === scrollElement) {
121+
if (recursiveCheck) {
122+
// our own implementation
123+
tryScrollRecursively(window.scrollTo, scrollPosition);
124+
} else {
125+
// using ember-app-scheduler
126+
window.scrollTo(scrollPosition.x, scrollPosition.y);
127+
}
81128
} else if ('#' === scrollElement.charAt(0)) {
82129
const element = document.getElementById(scrollElement.substring(1));
83130

@@ -103,12 +150,20 @@ let RouterScrollMixin = Mixin.create({
103150
}
104151

105152
const delayScrollTop = get(this, 'service.delayScrollTop');
153+
const scrollWhenPainted = get(this, 'service.scrollWhenPainted');
154+
const scrollWhenIdle = get(this, 'service.scrollWhenIdle');
106155

107-
if (!delayScrollTop) {
108-
scheduleOnce('render', this, () => this.updateScrollPosition(transition));
109-
} else {
156+
if (!delayScrollTop && !scrollWhenPainted && !scrollWhenIdle) {
157+
// out of the 3 options, this happens on the tightest schedule
158+
scheduleOnce('render', this, () => this.updateScrollPosition(transition, true));
159+
} else if (scrollWhenPainted) {
110160
// as described in ember-app-scheduler, this addon can be used to delay rendering until after First Meaningful Paint.
111161
// If you loading your routes progressively, this may be a good option to delay scrollTop until the remaining DOM elements are painted.
162+
whenRoutePainted().then(() => {
163+
this.updateScrollPosition(transition);
164+
});
165+
} else {
166+
// as described in ember-app-scheduler, this addon can be used to delay rendering until after the route is idle
112167
whenRouteIdle().then(() => {
113168
this.updateScrollPosition(transition);
114169
});

addon/services/router-scroll.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@ const RouterScroll = Service.extend({
1313
key: null,
1414
scrollElement: 'window',
1515
targetElement: null,
16-
delayScrollTop: false,
1716
isFirstLoad: true,
1817
preserveScrollPosition: false,
18+
delayScrollTop: false,
19+
// ember-app-scheduler properties
20+
scrollWhenPainted: false,
21+
scrollWhenIdle: false,
1922

2023
init(...args) {
2124
this._super(...args);
2225
this._loadConfig();
23-
set(this, 'scrollMap', { default: { x: 0, y: 0 }});
26+
set(this, 'scrollMap', {
27+
default: {
28+
x: 0, y: 0
29+
}
30+
});
2431
},
2532

2633
unsetFirstLoad() {
@@ -47,7 +54,10 @@ const RouterScroll = Service.extend({
4754

4855
// if we are looking to where to transition to next, we need to set the default to the position
4956
// of the targetElement on screen
50-
set(scrollMap, 'default', { x, y });
57+
set(scrollMap, 'default', {
58+
x,
59+
y
60+
});
5161
}
5262
} else if ('window' === scrollElement) {
5363
x = window.scrollX;
@@ -63,7 +73,10 @@ const RouterScroll = Service.extend({
6373

6474
// only a `key` present after first load
6575
if (key && 'number' === typeOf(x) && 'number' === typeOf(y)) {
66-
set(scrollMap, key, { x, y });
76+
set(scrollMap, key, {
77+
x,
78+
y
79+
});
6780
}
6881
},
6982

@@ -84,10 +97,14 @@ const RouterScroll = Service.extend({
8497
set(this, 'targetElement', targetElement);
8598
}
8699

87-
const delayScrollTop = config.routerScroll.delayScrollTop;
88-
if (delayScrollTop === true) {
89-
set(this, 'delayScrollTop', true);
90-
}
100+
const {
101+
scrollWhenPainted = false,
102+
scrollWhenIdle = false,
103+
delayScrollTop = false
104+
} = config.routerScroll;
105+
set(this, 'delayScrollTop', delayScrollTop);
106+
set(this, 'scrollWhenPainted', scrollWhenPainted);
107+
set(this, 'scrollWhenIdle', scrollWhenIdle);
91108
}
92109
}
93110
});

addon/utils/scrollbar-width.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// https://stackoverflow.com/questions/13382516/getting-scroll-bar-width-using-javascript
2+
export function getScrollBarWidth() {
3+
let outer = document.createElement('div');
4+
outer.style.visibility = 'hidden';
5+
outer.style.width = '100px';
6+
outer.style.msOverflowStyle = 'scrollbar';
7+
8+
document.body.appendChild(outer);
9+
10+
let widthNoScroll = outer.offsetWidth;
11+
// force scrollbars
12+
outer.style.overflow = 'scroll';
13+
14+
// add innerdiv
15+
let inner = document.createElement('div');
16+
inner.style.width = '100%';
17+
outer.appendChild(inner);
18+
19+
let widthWithScroll = inner.offsetWidth;
20+
21+
// remove divs
22+
outer.parentNode.removeChild(outer);
23+
24+
return widthNoScroll - widthWithScroll;
25+
}

0 commit comments

Comments
 (0)