Skip to content

Commit 0cb67f4

Browse files
committed
Take in ember-url-hash-polyfill
1 parent 8e895ae commit 0cb67f4

File tree

1 file changed

+189
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)