Skip to content

Commit 5702551

Browse files
authored
Support hash-only transitions in proper-links (#336)
* Support hash-only transitions in proper-links * Add example to docs * Add unit tests for shouldHandle
1 parent 4912237 commit 5702551

File tree

3 files changed

+117
-49
lines changed

3 files changed

+117
-49
lines changed

docs-app/public/docs/4-routing/proper-links.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import `properLinks` and apply it to your Router.
2525

2626
Once `@properLinks` is installed and setup, you can use plain `<a>` tags for navigation like this
2727

28-
```gjs live preview
28+
```gjs live preview
2929
<template>
30-
<nav style="display: flex; gap: 0.5rem">
31-
<a href="/">Home</a>
32-
<a href="/4-routing/link">Link docs</a>
33-
<a href="/4-routing/external-link">ExternalLink docs</a>
34-
<a href="https://developer.mozilla.org">MDN ➚</a>
30+
<nav id="example" style="display: flex; gap: 0.5rem">
31+
<a href="/">Home</a>
32+
<a href="#example">Link using a hash</a>
33+
<a href="/4-routing/link">Link docs</a>
34+
<a href="/4-routing/external-link">ExternalLink docs</a>
35+
<a href="https://developer.mozilla.org">MDN ➚</a>
3536
</nav>
3637
</template>
3738
```

ember-primitives/src/proper-links.ts

Lines changed: 77 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,63 @@ export function handle(
109109
ignore: string[],
110110
event: MouseEvent
111111
) {
112-
if (!element) return;
112+
if (!shouldHandle(location.href, element, ignore, event)) {
113+
return;
114+
}
115+
116+
let url = new URL(element.href);
117+
118+
let fullHref = `${url.pathname}${url.search}${url.hash}`;
119+
120+
let rootURL = router.rootURL;
121+
122+
let withoutRootURL = fullHref.slice(rootURL.length);
123+
124+
// re-add the "root" sigil
125+
// we removed it when we chopped off the rootURL,
126+
// because the rootURL often has this attached to it as well
127+
if (!withoutRootURL.startsWith('/')) {
128+
withoutRootURL = `/${withoutRootURL}`;
129+
}
130+
131+
try {
132+
let routeInfo = router.recognize(fullHref);
133+
134+
if (routeInfo) {
135+
event.preventDefault();
136+
event.stopImmediatePropagation();
137+
event.stopPropagation();
138+
139+
router.transitionTo(withoutRootURL);
140+
141+
return false;
142+
}
143+
} catch (e) {
144+
if (e instanceof Error && e.name === 'UnrecognizedURLError') {
145+
return;
146+
}
147+
148+
throw e;
149+
}
150+
}
151+
152+
/**
153+
* Returns `true` if the link should be handled by the Ember router
154+
* Returns `false` if the link should be handled by the browser
155+
*/
156+
export function shouldHandle(
157+
href: string,
158+
element: HTMLAnchorElement,
159+
ignore: string[],
160+
event: MouseEvent
161+
) {
162+
if (!element) return false;
113163
/**
114164
* If we don't have an href, the <a> is invalid.
115165
* If you're debugging your code and end up finding yourself
116166
* early-returning here, please add an href ;)
117167
*/
118-
if (!element.href) return;
168+
if (!element.href) return false;
119169

120170
/**
121171
* This is partially an escape hatch, but any time target is set,
@@ -129,91 +179,75 @@ export function handle(
129179
* "proper links" is to do what is expected, always -- for in-app SPA links
130180
* as well as external, cross-domain links
131181
*/
132-
if (element.target) return;
182+
if (element.target) return false;
133183

134184
/**
135185
* If the click is not a "left click" we don't want to intercept the event.
136186
* This allows folks to
137187
* - middle click (usually open the link in a new tab)
138188
* - right click (usually opens the context menu)
139189
*/
140-
if (event.button !== 0) return;
190+
if (event.button !== 0) return false;
141191

142192
/**
143193
* for MacOS users, this default behavior opens the link in a new tab
144194
*/
145-
if (event.metaKey) return;
195+
if (event.metaKey) return false;
146196

147197
/**
148198
* for for everyone else, this default behavior opens the link in a new tab
149199
*/
150-
if (event.ctrlKey) return;
200+
if (event.ctrlKey) return false;
151201

152202
/**
153203
* The default behavior here downloads the link content
154204
*/
155-
if (event.altKey) return;
205+
if (event.altKey) return false;
156206

157207
/**
158208
* The default behavior here opens the link in a new window
159209
*/
160-
if (event.shiftKey) return;
210+
if (event.shiftKey) return false;
161211

162212
/**
163213
* If another event listener called event.preventDefault(), we don't want to proceed.
164214
*/
165-
if (event.defaultPrevented) return;
215+
if (event.defaultPrevented) return false;
166216

167217
/**
168218
* The href includes the protocol/host/etc
169219
* In order to not have the page look like a full page refresh,
170220
* we need to chop that "origin" off, and just use the path
171221
*/
172222
let url = new URL(element.href);
223+
let location = new URL(href);
173224

174225
/**
175226
* If the domains are different, we want to fall back to normal link behavior
176227
*
177228
*/
178-
if (location.origin !== url.origin) return;
229+
if (location.origin !== url.origin) return false;
179230

180231
/**
181-
* We can optionally declare some paths as ignored,
182-
* or "let the browser do its default thing,
183-
* because there is other server-based routing to worry about"
232+
* Hash-only links are handled by the browser, except for the case where the
233+
* hash is being removed entirely, e.g. /foo#bar to /foo. In that case the
234+
* browser will do a full page refresh which is not what we want. Instead
235+
* we let the router handle such transitions. The current implementation of
236+
* the Ember router will skip the transition in this case because the path
237+
* is the same.
184238
*/
185-
if (ignore.includes(url.pathname)) return;
186-
187-
let fullHref = `${url.pathname}${url.search}${url.hash}`;
188-
189-
let rootURL = router.rootURL;
239+
let [prehash, posthash] = url.href.split('#');
190240

191-
let withoutRootURL = fullHref.slice(rootURL.length);
192-
193-
// re-add the "root" sigil
194-
// we removed it when we chopped off the rootURL,
195-
// because the rootURL often has this attached to it as well
196-
if (!withoutRootURL.startsWith('/')) {
197-
withoutRootURL = `/${withoutRootURL}`;
241+
if (posthash !== undefined && prehash === location.href.split('#')[0]) {
242+
return false;
198243
}
199244

200-
try {
201-
let routeInfo = router.recognize(fullHref);
202-
203-
if (routeInfo) {
204-
event.preventDefault();
205-
event.stopImmediatePropagation();
206-
event.stopPropagation();
207-
208-
router.transitionTo(withoutRootURL);
209-
210-
return false;
211-
}
212-
} catch (e) {
213-
if (e instanceof Error && e.name === 'UnrecognizedURLError') {
214-
return;
215-
}
245+
/**
246+
* We can optionally declare some paths as ignored,
247+
* or "let the browser do its default thing,
248+
* because there is other server-based routing to worry about"
249+
*/
250+
if (ignore.includes(url.pathname)) return false;
216251

217-
throw e;
218-
}
252+
return true;
219253
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { module, test } from 'qunit';
2+
3+
import { shouldHandle } from 'ember-primitives/proper-links';
4+
5+
module('@properLinks', function () {
6+
module('shouldHandle', function () {
7+
test('hash-only links', async function (assert) {
8+
function assertShouldHandle(
9+
expected: boolean,
10+
{ location, href }: { location: string; href: string }
11+
) {
12+
let url = new URL(location, 'https://example.com');
13+
14+
let anchor = document.createElement('a');
15+
16+
anchor.href = new URL(href, url).href;
17+
18+
let ignored: string[] = [];
19+
let simpleClickEvent = new MouseEvent('click');
20+
21+
assert.strictEqual(shouldHandle(url.href, anchor, ignored, simpleClickEvent), expected);
22+
}
23+
24+
assertShouldHandle(false, { location: '/foo', href: '/foo#bar' });
25+
assertShouldHandle(false, { location: '/foo', href: '#bar' });
26+
assertShouldHandle(false, { location: '/foo#bar', href: '/foo#other' });
27+
28+
assertShouldHandle(true, { location: '/foo', href: '/abc#xyz' });
29+
assertShouldHandle(true, { location: '/foo#bar', href: '/abc#xyz' });
30+
assertShouldHandle(true, { location: '/foo#bar', href: '/foo' });
31+
});
32+
});
33+
});

0 commit comments

Comments
 (0)