Skip to content

Commit 2d96b46

Browse files
committed
Support scrolling to anchors for both pages rendered initially with fastboot and those that are not
- Move to supported fork of ember-router-scroll
1 parent df179e2 commit 2d96b46

File tree

5 files changed

+246
-178
lines changed

5 files changed

+246
-178
lines changed

app/router.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import EmberRouter from '@ember/routing/router';
22
import config from 'ember-api-docs/config/environment';
3+
import { withHashSupport } from 'ember-api-docs/utils/url-hash-polyfill';
34

5+
// The following adds support for URL hash routing for those URLs not rendered with fastboot
6+
@withHashSupport
47
class AppRouter extends EmberRouter {
58
location = config.locationType;
69
rootURL = config.routerRootURL;

app/routes/application.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { inject as service } from '@ember/service';
22
import Route from '@ember/routing/route';
33
import { set } from '@ember/object';
44
import ENV from 'ember-api-docs/config/environment';
5+
import { isDestroying, isDestroyed } from '@ember/destroyable';
56

67
export default class ApplicationRoute extends Route {
78
@service
@@ -19,10 +20,25 @@ export default class ApplicationRoute extends Route {
1920
@service
2021
metrics;
2122

23+
@service
24+
routerScroll;
25+
2226
constructor() {
2327
super(...arguments);
2428
if (!this.fastboot.isFastBoot) {
2529
this.router.on('routeDidChange', this.trackPage);
30+
31+
/* Hax from https://github.com/DockYard/ember-router-scroll/issues/263
32+
to handle router scroll behavior when the page was initially served
33+
with fastboot
34+
*/
35+
this.routerScroll.set('preserveScrollPosition', true);
36+
37+
setTimeout(() => {
38+
if (!isDestroying(this) && !isDestroyed(this)) {
39+
this.routerScroll.set('preserveScrollPosition', false);
40+
}
41+
}, 1000);
2642
}
2743
}
2844

app/utils/url-hash-polyfill.js

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { getOwner } from '@ember/owner';
2+
import { warn } from '@ember/debug';
3+
import {
4+
isDestroyed,
5+
isDestroying,
6+
registerDestructor,
7+
} from '@ember/destroyable';
8+
import { schedule } from '@ember/runloop';
9+
import { waitForPromise } from '@ember/test-waiters';
10+
11+
/* Taken from ember-url-hash-polyfill (https://github.com/CrowdStrike/ember-url-hash-polyfill/)
12+
and modified to not run in Fastboot. The original addon is not maintained.
13+
There is a PR to add it to ember-primitives https://github.com/universal-ember/ember-primitives/pull/529
14+
*/
15+
16+
export function withHashSupport(AppRouter) {
17+
return class RouterWithHashSupport extends AppRouter {
18+
constructor(...args) {
19+
super(...args);
20+
21+
setupHashSupport(this);
22+
}
23+
};
24+
}
25+
26+
export function scrollToHash(hash) {
27+
let selector = `[name="${hash}"]`;
28+
let element =
29+
document.getElementById(hash) || document.querySelector(selector);
30+
31+
if (!element) {
32+
warn(
33+
`Tried to scroll to element with id or name "${hash}", but it was not found`,
34+
{
35+
id: 'no-hash-target',
36+
},
37+
);
38+
39+
return;
40+
}
41+
42+
/**
43+
* NOTE: the ember router does not support hashes in the URL
44+
* https://github.com/emberjs/rfcs/issues/709
45+
*
46+
* this means that when testing hash changes in the URL,
47+
* we have to assert against the window.location, rather than
48+
* the self-container currentURL helper
49+
*
50+
* NOTE: other ways of changing the URL, but without the smoothness:
51+
* - window[.top].location.replace
52+
*/
53+
54+
element.scrollIntoView({ behavior: 'smooth' });
55+
56+
if (hash !== window.location.hash) {
57+
let withoutHash = location.href.split('#')[0];
58+
let nextUrl = `${withoutHash}#${hash}`;
59+
// most browsers ignore the title param of pushState
60+
let titleWithoutHash = document.title.split(' | #')[0];
61+
let nextTitle = `${titleWithoutHash} | #${hash}`;
62+
63+
history.pushState({}, nextTitle, nextUrl);
64+
document.title = nextTitle;
65+
}
66+
}
67+
68+
function isLoadingRoute(routeName) {
69+
return routeName.endsWith('_loading') || routeName.endsWith('.loading');
70+
}
71+
72+
async function setupHashSupport(router) {
73+
let initialURL;
74+
let owner = getOwner(router);
75+
76+
if (owner.lookup('service:fastboot').isFastBoot) {
77+
return;
78+
}
79+
80+
await new Promise((resolve) => {
81+
let interval = setInterval(() => {
82+
let { currentURL, currentRouteName } = router; /* Private API */
83+
84+
if (currentURL && !isLoadingRoute(currentRouteName)) {
85+
clearInterval(interval);
86+
initialURL = currentURL;
87+
resolve(null);
88+
}
89+
}, 100);
90+
});
91+
92+
if (isDestroyed(owner) || isDestroying(owner)) {
93+
return;
94+
}
95+
96+
/**
97+
* This handles the initial Page Load, which is not imperceptible through
98+
* route{Did,Will}Change
99+
*
100+
*/
101+
requestAnimationFrame(() => {
102+
eventuallyTryScrollingTo(owner, initialURL);
103+
});
104+
105+
let routerService = owner.lookup('service:router');
106+
107+
function handleHashIntent(transition) {
108+
let { url } = transition.intent || {};
109+
110+
if (!url) {
111+
return;
112+
}
113+
114+
eventuallyTryScrollingTo(owner, url);
115+
}
116+
117+
routerService.on('routeDidChange', handleHashIntent);
118+
119+
registerDestructor(router, () => {
120+
routerService.off('routeDidChange', handleHashIntent);
121+
});
122+
}
123+
124+
const CACHE = new WeakMap();
125+
126+
async function eventuallyTryScrollingTo(owner, url) {
127+
// Prevent quick / rapid transitions from continuing to observer beyond their URL-scope
128+
CACHE.get(owner)?.disconnect();
129+
130+
if (!url) return;
131+
132+
let [, hash] = url.split('#');
133+
134+
if (!hash) return;
135+
136+
await waitForPromise(uiSettled(owner));
137+
138+
if (isDestroyed(owner) || isDestroying(owner)) {
139+
return;
140+
}
141+
142+
scrollToHash(hash);
143+
}
144+
145+
const TIME_SINCE_LAST_MUTATION = 500; // ms
146+
const MAX_TIMEOUT = 2000; // ms
147+
148+
// exported for testing
149+
async function uiSettled(owner) {
150+
let timeStarted = new Date().getTime();
151+
let lastMutationAt = Infinity;
152+
let totalTimeWaited = 0;
153+
154+
let observer = new MutationObserver(() => {
155+
lastMutationAt = new Date().getTime();
156+
});
157+
158+
CACHE.set(owner, observer);
159+
160+
observer.observe(document.body, { childList: true, subtree: true });
161+
162+
/**
163+
* Wait for DOM mutations to stop until MAX_TIMEOUT
164+
*/
165+
await new Promise((resolve) => {
166+
let frame;
167+
168+
function requestTimeCheck() {
169+
if (frame) cancelAnimationFrame(frame);
170+
171+
if (isDestroyed(owner) || isDestroying(owner)) {
172+
return;
173+
}
174+
175+
frame = requestAnimationFrame(() => {
176+
totalTimeWaited = new Date().getTime() - timeStarted;
177+
178+
let timeSinceLastMutation = new Date().getTime() - lastMutationAt;
179+
180+
if (totalTimeWaited >= MAX_TIMEOUT) {
181+
return resolve(totalTimeWaited);
182+
}
183+
184+
if (timeSinceLastMutation >= TIME_SINCE_LAST_MUTATION) {
185+
return resolve(totalTimeWaited);
186+
}
187+
188+
// eslint-disable-next-line ember/no-runloop
189+
schedule('afterRender', requestTimeCheck);
190+
});
191+
}
192+
193+
// eslint-disable-next-line ember/no-runloop
194+
schedule('afterRender', requestTimeCheck);
195+
});
196+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
"ember-qunit": "^6.2.0",
101101
"ember-resolver": "^10.0.0",
102102
"ember-rfc176-data": "^0.3.17",
103-
"ember-router-scroll": "^4.1.2",
103+
"@nullvoxpopuli/ember-router-scroll": "^0.0.2",
104104
"ember-showdown-shiki": "^1.2.1",
105105
"ember-sinon": "^4.1.1",
106106
"ember-source": "~4.12.0",

0 commit comments

Comments
 (0)