Skip to content

Commit

Permalink
feat: Adds scope prop to LocationProvider, limiting link intercep…
Browse files Browse the repository at this point in the history
…ts (#42)
  • Loading branch information
rschristian authored Oct 17, 2024
1 parent 4c9ed82 commit 6c4f217
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 8 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,17 @@ export async function prerender(data) {

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

Props:

- `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.

Typically, you would wrap your entire app in this provider:

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

const App = () => (
<LocationProvider>
<LocationProvider scope="/app">
{/* Your app here */}
</LocationProvider>
);
Expand Down
7 changes: 5 additions & 2 deletions src/router.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { AnyComponent, FunctionComponent, VNode } from 'preact';
import { AnyComponent, ComponentChildren, FunctionComponent, VNode } from 'preact';

export const LocationProvider: FunctionComponent;
export function LocationProvider(props: {
scope?: string | RegExp;
children?: ComponentChildren;
}): VNode;

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

Expand Down
18 changes: 14 additions & 4 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact
* @typedef {import('./internal.d.ts').VNode} VNode
*/

let push;
let push, scope;
const UPDATE = (state, url) => {
push = undefined;
if (url && url.type === 'click') {
Expand All @@ -16,12 +16,17 @@ const UPDATE = (state, url) => {
return state;
}

const link = url.target.closest('a[href]');
const link = url.target.closest('a[href]'),
href = link.getAttribute('href');
if (
!link ||
link.origin != location.origin ||
/^#/.test(link.getAttribute('href')) ||
!/^(_?self)?$/i.test(link.target)
/^#/.test(href) ||
!/^(_?self)?$/i.test(link.target) ||
scope && (typeof scope == 'string'
? !href.startsWith(scope)
: !scope.test(href)
)
) {
return state;
}
Expand Down Expand Up @@ -70,8 +75,13 @@ export const exec = (url, route, matches) => {
return matches;
};

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

const value = useMemo(() => {
Expand Down
124 changes: 123 additions & 1 deletion test/router.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,6 @@ describe('Router', () => {
const shouldIntercept = [null, '', '_self', 'self', '_SELF'];
const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK'];

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

const Route = sinon.fake(
Expand Down Expand Up @@ -583,6 +582,129 @@ describe('Router', () => {
}
});

describe('intercepted VS external links with `scope`', () => {
const shouldIntercept = ['/app', '/app/deeper'];
const shouldNavigate = ['/site', '/site/deeper'];

const clickHandler = sinon.fake(e => e.preventDefault());

const Links = () => (
<>
<a href="/app">Internal Link</a>
<a href="/app/deeper">Internal Deeper Link</a>
<a href="/site">External Link</a>
<a href="/site/deeper">External Deeper Link</a>
</>
);

let pushState;

before(() => {
pushState = sinon.spy(history, 'pushState');
addEventListener('click', clickHandler);
});

after(() => {
pushState.restore();
removeEventListener('click', clickHandler);
});

beforeEach(async () => {
clickHandler.resetHistory();
pushState.resetHistory();
});

it('should intercept clicks on links matching the `scope` props (string)', async () => {
render(
<LocationProvider scope="/app">
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
await sleep(10);

for (const url of shouldIntercept) {
const el = scratch.querySelector(`a[href="${url}"]`);
el.click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;

pushState.resetHistory();
clickHandler.resetHistory();
}
});

it('should allow default browser navigation for links not matching the `limit` props (string)', async () => {
render(
<LocationProvider scope="app">
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
await sleep(10);

for (const url of shouldNavigate) {
const el = scratch.querySelector(`a[href="${url}"]`);
el.click();
await sleep(1);
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;

pushState.resetHistory();
clickHandler.resetHistory();
}
});

it('should intercept clicks on links matching the `limit` props (regex)', async () => {
render(
<LocationProvider scope={/^\/app/}>
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
await sleep(10);

for (const url of shouldIntercept) {
const el = scratch.querySelector(`a[href="${url}"]`);
el.click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;

pushState.resetHistory();
clickHandler.resetHistory();
}
});

it('should allow default browser navigation for links not matching the `limit` props (regex)', async () => {
render(
<LocationProvider scope={/^\/app/}>
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
await sleep(10);

for (const url of shouldNavigate) {
const el = scratch.querySelector(`a[href="${url}"]`);
el.click();
await sleep(1);
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;

pushState.resetHistory();
clickHandler.resetHistory();
}
});
});

it('should scroll to top when navigating forward', async () => {
const scrollTo = sinon.spy(window, 'scrollTo');

Expand Down

0 comments on commit 6c4f217

Please sign in to comment.