Skip to content

Commit f747478

Browse files
committed
refactor: Finish up for now
1 parent acf137f commit f747478

File tree

2 files changed

+107
-80
lines changed

2 files changed

+107
-80
lines changed

src/router.js

+23-7
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,37 @@ import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact
1010
/** @type {string | RegExp | undefined} */
1111
let scope;
1212

13+
/**
14+
* @param {URL} url
15+
* @returns {boolean}
16+
*/
17+
function isInScope(url) {
18+
return !scope || (typeof scope == 'string'
19+
? url.pathname.startsWith(scope)
20+
: scope.test(url.pathname)
21+
);
22+
}
23+
1324
/**
1425
* @param {string} state
1526
* @param {NavigateEvent} e
1627
*/
1728
function handleNav(state, e) {
18-
if (!e.canIntercept) return state;
19-
if (e.hashChange || e.downloadRequest !== null) return state;
20-
29+
// TODO: Double-check this can't fail to parse.
30+
// `.destination` is read-only, so I'm hoping it guarantees a valid URL.
2131
const url = new URL(e.destination.url);
32+
2233
if (
23-
scope && (typeof scope == 'string'
24-
? !url.pathname.startsWith(scope)
25-
: !scope.test(url.pathname)
26-
)
34+
!e.canIntercept ||
35+
e.hashChange ||
36+
e.downloadRequest !== null ||
37+
// Not yet implemented by Chrome, but coming?
38+
//!/^(_?self)?$/i.test(/** @type {HTMLAnchorElement} */ (e.sourceElement).target) ||
39+
!isInScope(url)
2740
) {
41+
// We only set this for our tests, it's otherwise very difficult to
42+
// determine if a navigation was intercepted or not externally.
43+
e['preact-iso-ignored'] = true;
2844
return state;
2945
}
3046

test/router.test.js

+84-73
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { h, Fragment, render, hydrate, options } from 'preact';
2-
import { useState } from 'preact/hooks';
2+
import { useEffect, useState } from 'preact/hooks';
33
import * as chai from 'chai';
44
import * as sinon from 'sinon';
55
import sinonChai from 'sinon-chai';
@@ -512,15 +512,13 @@ describe('Router', () => {
512512
expect(loadEnd).not.to.have.been.called;
513513
});
514514

515-
// TODO
515+
// TODO: Relies on upcoming property being added to navigation events
516516
describe.skip('intercepted VS external links', () => {
517517
const shouldIntercept = [null, '', '_self', 'self', '_SELF'];
518518
const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK'];
519519

520-
const clickHandler = sinon.fake(e => e.preventDefault());
521-
522-
const Route = sinon.fake(
523-
() => <div>
520+
const Route = () => (
521+
<div>
524522
{[...shouldIntercept, ...shouldNavigate].map((target, i) => {
525523
const url = '/' + i + '/' + target;
526524
if (target === null) return <a href={url}>target = {target + ''}</a>;
@@ -529,31 +527,32 @@ describe('Router', () => {
529527
</div>
530528
);
531529

532-
let pushState;
533-
534-
before(() => {
535-
pushState = sinon.spy(history, 'pushState');
536-
addEventListener('click', clickHandler);
537-
});
538-
539-
after(() => {
540-
pushState.restore();
541-
removeEventListener('click', clickHandler);
542-
});
530+
let triedToNavigate = false;
531+
const handler = (e) => {
532+
e.intercept();
533+
if (e['preact-iso-ignored']) {
534+
triedToNavigate = true;
535+
}
536+
}
543537

544538
beforeEach(async () => {
545-
render(
546-
<LocationProvider>
547-
<Router>
548-
<Route default />
549-
</Router>
550-
<ShallowLocation />
551-
</LocationProvider>,
552-
scratch
553-
);
554-
Route.resetHistory();
555-
clickHandler.resetHistory();
556-
pushState.resetHistory();
539+
const App = () => {
540+
useEffect(() => {
541+
navigation.addEventListener('navigate', handler);
542+
return () => navigation.removeEventListener('navigate', handler);
543+
}, []);
544+
545+
return (
546+
<LocationProvider>
547+
<Router>
548+
<Route default />
549+
</Router>
550+
<ShallowLocation />
551+
</LocationProvider>
552+
);
553+
}
554+
render(<App />, scratch);
555+
await sleep(10);
557556
});
558557

559558
const getName = target => (target == null ? 'no target attribute' : `target="${target}"`);
@@ -568,9 +567,9 @@ describe('Router', () => {
568567
el.click();
569568
await sleep(1);
570569
expect(loc).to.deep.include({ url });
571-
expect(Route).to.have.been.calledOnce;
572-
expect(pushState).to.have.been.calledWith(null, '', url);
573-
expect(clickHandler).to.have.been.called;
570+
expect(triedToNavigate).to.be.false;
571+
572+
triedToNavigate = false;
574573
});
575574
}
576575

@@ -582,9 +581,9 @@ describe('Router', () => {
582581
if (!el) throw Error(`Unable to find link: ${sel}`);
583582
el.click();
584583
await sleep(1);
585-
expect(Route).not.to.have.been.called;
586-
expect(pushState).not.to.have.been.called;
587-
expect(clickHandler).to.have.been.called;
584+
expect(triedToNavigate).to.be.true;
585+
586+
triedToNavigate = false;
588587
});
589588
}
590589
});
@@ -602,69 +601,81 @@ describe('Router', () => {
602601
</>
603602
);
604603

605-
it('should intercept clicks on links matching the `scope` props (string)', async () => {
606-
render(
607-
<LocationProvider scope="/app">
608-
<Links />
609-
<ShallowLocation />
610-
</LocationProvider>,
611-
scratch
612-
);
604+
let triedToNavigate = false;
605+
const handler = (e) => {
606+
e.intercept();
607+
if (e['preact-iso-ignored']) {
608+
triedToNavigate = true;
609+
}
610+
}
611+
612+
it('should support the `scope` prop (string)', async () => {
613+
const App = () => {
614+
useEffect(() => {
615+
navigation.addEventListener('navigate', handler);
616+
return () => navigation.removeEventListener('navigate', handler);
617+
}, []);
618+
619+
return (
620+
<LocationProvider scope="/app">
621+
<Links />
622+
<ShallowLocation />
623+
</LocationProvider>
624+
);
625+
}
626+
render(<App />, scratch);
627+
await sleep(10);
613628

614629
for (const url of shouldIntercept) {
615630
scratch.querySelector(`a[href="${url}"]`).click();
616631
await sleep(1);
617632
expect(loc).to.deep.include({ url });
618-
}
619-
});
633+
expect(triedToNavigate).to.be.false;
620634

621-
it.skip('should allow default browser navigation for links not matching the `scope` props (string)', async () => {
622-
render(
623-
<LocationProvider scope="app">
624-
<Links />
625-
<ShallowLocation />
626-
</LocationProvider>,
627-
scratch
628-
);
635+
triedToNavigate = false;
636+
}
629637

630638
for (const url of shouldNavigate) {
631639
scratch.querySelector(`a[href="${url}"]`).click();
632640
await sleep(1);
641+
expect(triedToNavigate).to.be.true;
633642

634-
// TODO: How to test this?
643+
triedToNavigate = false;
635644
}
636645
});
637646

638-
it('should intercept clicks on links matching the `scope` props (regex)', async () => {
639-
render(
640-
<LocationProvider scope={/^\/app/}>
641-
<Links />
642-
<ShallowLocation />
643-
</LocationProvider>,
644-
scratch
645-
);
647+
it('should support the `scope` prop (regex)', async () => {
648+
const App = () => {
649+
useEffect(() => {
650+
navigation.addEventListener('navigate', handler);
651+
return () => navigation.removeEventListener('navigate', handler);
652+
}, []);
653+
654+
return (
655+
<LocationProvider scope={/^\/app/}>
656+
<Links />
657+
<ShallowLocation />
658+
</LocationProvider>
659+
);
660+
}
661+
render(<App />, scratch);
662+
await sleep(10);
646663

647664
for (const url of shouldIntercept) {
648665
scratch.querySelector(`a[href="${url}"]`).click();
649666
await sleep(1);
650667
expect(loc).to.deep.include({ url });
651-
}
652-
});
668+
expect(triedToNavigate).to.be.false;
653669

654-
it.skip('should allow default browser navigation for links not matching the `scope` props (regex)', async () => {
655-
render(
656-
<LocationProvider scope={/^\/app/}>
657-
<Links />
658-
<ShallowLocation />
659-
</LocationProvider>,
660-
scratch
661-
);
670+
triedToNavigate = false;
671+
}
662672

663673
for (const url of shouldNavigate) {
664674
scratch.querySelector(`a[href="${url}"]`).click();
665675
await sleep(1);
676+
expect(triedToNavigate).to.be.true;
666677

667-
// TODO: How to test this?
678+
triedToNavigate = false;
668679
}
669680
});
670681
});

0 commit comments

Comments
 (0)