Skip to content

Commit 6c4f217

Browse files
authored
feat: Adds scope prop to LocationProvider, limiting link intercepts (#42)
1 parent 4c9ed82 commit 6c4f217

File tree

4 files changed

+147
-8
lines changed

4 files changed

+147
-8
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,17 @@ export async function prerender(data) {
8282

8383
A context provider that provides the current location to its children. This is required for the router to function.
8484

85+
Props:
86+
87+
- `scope?: string | RegExp` - Sets a scope for the paths that the router will handle (intercept). If a path does not match the scope, either by starting with the provided string or matching the RegExp, the router will ignore it and default browser navigation will apply.
88+
8589
Typically, you would wrap your entire app in this provider:
8690

8791
```js
8892
import { LocationProvider } from 'preact-iso';
8993

9094
const App = () => (
91-
<LocationProvider>
95+
<LocationProvider scope="/app">
9296
{/* Your app here */}
9397
</LocationProvider>
9498
);

src/router.d.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { AnyComponent, FunctionComponent, VNode } from 'preact';
1+
import { AnyComponent, ComponentChildren, FunctionComponent, VNode } from 'preact';
22

3-
export const LocationProvider: FunctionComponent;
3+
export function LocationProvider(props: {
4+
scope?: string | RegExp;
5+
children?: ComponentChildren;
6+
}): VNode;
47

58
type NestedArray<T> = Array<T | NestedArray<T>>;
69

src/router.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact
77
* @typedef {import('./internal.d.ts').VNode} VNode
88
*/
99

10-
let push;
10+
let push, scope;
1111
const UPDATE = (state, url) => {
1212
push = undefined;
1313
if (url && url.type === 'click') {
@@ -16,12 +16,17 @@ const UPDATE = (state, url) => {
1616
return state;
1717
}
1818

19-
const link = url.target.closest('a[href]');
19+
const link = url.target.closest('a[href]'),
20+
href = link.getAttribute('href');
2021
if (
2122
!link ||
2223
link.origin != location.origin ||
23-
/^#/.test(link.getAttribute('href')) ||
24-
!/^(_?self)?$/i.test(link.target)
24+
/^#/.test(href) ||
25+
!/^(_?self)?$/i.test(link.target) ||
26+
scope && (typeof scope == 'string'
27+
? !href.startsWith(scope)
28+
: !scope.test(href)
29+
)
2530
) {
2631
return state;
2732
}
@@ -70,8 +75,13 @@ export const exec = (url, route, matches) => {
7075
return matches;
7176
};
7277

78+
/**
79+
* @type {import('./router.d.ts').LocationProvider}
80+
*/
7381
export function LocationProvider(props) {
82+
// @ts-expect-error - props.url is not implemented correctly & will be removed in the future
7483
const [url, route] = useReducer(UPDATE, props.url || location.pathname + location.search);
84+
if (props.scope) scope = props.scope;
7585
const wasPush = push === true;
7686

7787
const value = useMemo(() => {

test/router.test.js

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,6 @@ describe('Router', () => {
505505
const shouldIntercept = [null, '', '_self', 'self', '_SELF'];
506506
const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK'];
507507

508-
// prevent actual navigations (not implemented in JSDOM)
509508
const clickHandler = sinon.fake(e => e.preventDefault());
510509

511510
const Route = sinon.fake(
@@ -583,6 +582,129 @@ describe('Router', () => {
583582
}
584583
});
585584

585+
describe('intercepted VS external links with `scope`', () => {
586+
const shouldIntercept = ['/app', '/app/deeper'];
587+
const shouldNavigate = ['/site', '/site/deeper'];
588+
589+
const clickHandler = sinon.fake(e => e.preventDefault());
590+
591+
const Links = () => (
592+
<>
593+
<a href="/app">Internal Link</a>
594+
<a href="/app/deeper">Internal Deeper Link</a>
595+
<a href="/site">External Link</a>
596+
<a href="/site/deeper">External Deeper Link</a>
597+
</>
598+
);
599+
600+
let pushState;
601+
602+
before(() => {
603+
pushState = sinon.spy(history, 'pushState');
604+
addEventListener('click', clickHandler);
605+
});
606+
607+
after(() => {
608+
pushState.restore();
609+
removeEventListener('click', clickHandler);
610+
});
611+
612+
beforeEach(async () => {
613+
clickHandler.resetHistory();
614+
pushState.resetHistory();
615+
});
616+
617+
it('should intercept clicks on links matching the `scope` props (string)', async () => {
618+
render(
619+
<LocationProvider scope="/app">
620+
<Links />
621+
<ShallowLocation />
622+
</LocationProvider>,
623+
scratch
624+
);
625+
await sleep(10);
626+
627+
for (const url of shouldIntercept) {
628+
const el = scratch.querySelector(`a[href="${url}"]`);
629+
el.click();
630+
await sleep(1);
631+
expect(loc).to.deep.include({ url });
632+
expect(pushState).to.have.been.calledWith(null, '', url);
633+
expect(clickHandler).to.have.been.called;
634+
635+
pushState.resetHistory();
636+
clickHandler.resetHistory();
637+
}
638+
});
639+
640+
it('should allow default browser navigation for links not matching the `limit` props (string)', async () => {
641+
render(
642+
<LocationProvider scope="app">
643+
<Links />
644+
<ShallowLocation />
645+
</LocationProvider>,
646+
scratch
647+
);
648+
await sleep(10);
649+
650+
for (const url of shouldNavigate) {
651+
const el = scratch.querySelector(`a[href="${url}"]`);
652+
el.click();
653+
await sleep(1);
654+
expect(pushState).not.to.have.been.called;
655+
expect(clickHandler).to.have.been.called;
656+
657+
pushState.resetHistory();
658+
clickHandler.resetHistory();
659+
}
660+
});
661+
662+
it('should intercept clicks on links matching the `limit` props (regex)', async () => {
663+
render(
664+
<LocationProvider scope={/^\/app/}>
665+
<Links />
666+
<ShallowLocation />
667+
</LocationProvider>,
668+
scratch
669+
);
670+
await sleep(10);
671+
672+
for (const url of shouldIntercept) {
673+
const el = scratch.querySelector(`a[href="${url}"]`);
674+
el.click();
675+
await sleep(1);
676+
expect(loc).to.deep.include({ url });
677+
expect(pushState).to.have.been.calledWith(null, '', url);
678+
expect(clickHandler).to.have.been.called;
679+
680+
pushState.resetHistory();
681+
clickHandler.resetHistory();
682+
}
683+
});
684+
685+
it('should allow default browser navigation for links not matching the `limit` props (regex)', async () => {
686+
render(
687+
<LocationProvider scope={/^\/app/}>
688+
<Links />
689+
<ShallowLocation />
690+
</LocationProvider>,
691+
scratch
692+
);
693+
await sleep(10);
694+
695+
for (const url of shouldNavigate) {
696+
const el = scratch.querySelector(`a[href="${url}"]`);
697+
el.click();
698+
await sleep(1);
699+
expect(pushState).not.to.have.been.called;
700+
expect(clickHandler).to.have.been.called;
701+
702+
pushState.resetHistory();
703+
clickHandler.resetHistory();
704+
}
705+
});
706+
});
707+
586708
it('should scroll to top when navigating forward', async () => {
587709
const scrollTo = sinon.spy(window, 'scrollTo');
588710

0 commit comments

Comments
 (0)