Skip to content

Commit

Permalink
refactor!: Move URL search param handling into seperate util (#198)
Browse files Browse the repository at this point in the history
  • Loading branch information
maxmilton authored Jan 31, 2022
1 parent 5eabfec commit de7ee3f
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 51 deletions.
44 changes: 36 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A lightweight History API based router for [Solid](https://github.com/solidjs/so
- Simple — single top level router, no nesting, no context, handles all `<a>` clicks
- Light — [few dependencies](https://npm.anvaka.com/#/view/2d/%2540maxmilton%252Fsolid-router); under the hood it's mostly an abstraction on top of Solid's built-in Switch and Match components + a little handling logic
- Flexible path matching — static paths, parameters, optional parameters, wildcards, and no match fallback
- URL search query params parsing
- Optional URL search query params parsing

> Note: This package is not designed to work with SSR or DOM-less pre-rendering. If you need a universal solution use [solid-app-router](https://github.com/solidjs/solid-app-router) instead.
Expand All @@ -32,7 +32,7 @@ yarn add @maxmilton/solid-router

## Usage

Simple + JavaScript:
### Simple + JavaScript

```jsx
import { NavLink, Router, routeTo } from '@maxmilton/solid-router';
Expand Down Expand Up @@ -72,11 +72,17 @@ const App = () => (
render(App, document.body);
```

All features + TypeScript:
### All features + TypeScript

```tsx
import { NavLink, Route, Router, routeTo } from '@maxmilton/solid-router';
import { Component, JSX, lazy } from 'solid-js';
import {
NavLink,
Router,
useURLParams,
routeTo,
type Route,
} from '@maxmilton/solid-router';
import { lazy, type Component, type JSX } from 'solid-js';
import { ErrorBoundary, render, Suspense } from 'solid-js/web';

interface ErrorPageProps {
Expand Down Expand Up @@ -108,8 +114,18 @@ const routes: Route[] = [
{
path: '/xx/:x1/:x2?',
component: (props) => {
console.log('PARAMS', props.params);
console.log('QUERY', props.query);
console.log(props.params); // -> { x1: "...", x2: ... }

const [urlParams, setUrlParams] = useURLParams();
console.log(urlParams()); // -> { ... }

// Add new URL params
setUrlParams({ ...urlParams(), name: 'example', x: [1, 2] }); // -> location.search == "?name=example&x=1&x=2"

// Delete URL params (set to `undefined`)
setUrlParams({ ...urlParams(), x: undefined }); // -> location.search == "?name=example"

// Regular links are still handled by the router
return <a href="/">I'm still handled correctly!</a>;
},
},
Expand Down Expand Up @@ -161,9 +177,21 @@ TODO: Write me
<!-- [regexparam](https://github.com/lukeed/regexparam) -->
<!-- [qss](https://github.com/lukeed/qss) -->

## Browser support

No particularly modern JavaScript APIs are used so browser support should be excellent. However, keep in mind [Solid's official browser support](https://github.com/solidjs/solid#browser-support) only targets modern evergreen browsers.

## Bugs

Report any bugs you encounter on the [GitHub issue tracker](https://github.com/maxmilton/new-tab/issues).

## Changelog

See [releases on GitHub](https://github.com/maxmilton/solid-router/releases).

## License

`@maxmilton/solid-router` is an MIT licensed open source project. See [LICENSE](https://github.com/maxmilton/solid-router/blob/master/LICENSE).
MIT license. See [LICENSE](https://github.com/maxmilton/solid-router/blob/master/LICENSE).

---

Expand Down
9 changes: 9 additions & 0 deletions prettier.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @type {import('prettier').Config} */
module.exports = {
arrowParens: 'always',
endOfLine: 'lf',
Expand All @@ -7,4 +8,12 @@ module.exports = {
'./node_modules/prettier-plugin-pkg',
'./node_modules/prettier-plugin-sh',
],
overrides: [
{
files: ['*.test.tsx', '*.test.ts'],
options: {
printWidth: 100,
},
},
],
};
50 changes: 38 additions & 12 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { decode } from 'qss';
import { decode, encode } from 'qss';
import { parse } from 'regexparam';
import {
Component,
createSignal,
JSX,
onCleanup,
splitProps,
startTransition,
type Accessor,
type Component,
type JSX,
} from 'solid-js';
import { Match, Switch } from 'solid-js/web';

const [urlPath, setUrlPath] = createSignal(window.location.pathname);

export function routeTo(url: string, replace?: boolean): Promise<void> {
window.history[`${replace ? 'replace' : 'push'}State` as const]({}, '', url);
window.history[`${replace ? 'replace' : 'push'}State`](null, '', url);
return startTransition(() => setUrlPath(/[^#?]*/.exec(url)![0]));
}

export type RouteComponent<P = Record<string, any>> = (
props: P & {
children?: JSX.Element;
readonly params: Record<string, string | null>;
readonly query: Partial<Record<string, any>>;
},
) => JSX.Element;

Expand Down Expand Up @@ -94,7 +94,6 @@ export const Router: Component<RouterProps> = (props) => {
return (
<Match when={pattern.exec(urlPath())}>
{(matches) => {
const search = window.location.search.slice(1);
const params: Record<string, string | null> = {};
let index = 0;

Expand All @@ -103,12 +102,7 @@ export const Router: Component<RouterProps> = (props) => {
}

// FIXME: Lazy loaded components do not trigger <Suspense>
return (
<route.component
params={params}
query={search ? decode(search) : {}}
/>
);
return <route.component params={params} />;
}}
</Match>
);
Expand Down Expand Up @@ -155,3 +149,35 @@ export const NavLink: Component<NavLinkProps> = (props) => {
/>
);
};

export type URLParams = Record<
string,
string | number | boolean | (string | number | boolean)[] | undefined
>;

/**
* The current URL search query params parsed into a reactive object.
*
* When updating the object via the setter function, the URL will be updated
* as well.
*
* Note that the object is _not live_, meaning that if the URL query params
* change externally (e.g., with `history.replaceState`), the object will not
* update automatically.
*/
export const useURLParams = (): [
Accessor<URLParams>,
/**
* @param params - The new URL search query params to set. Properties set as
* `undefined` will not be included in the URL.
*/
(params: URLParams) => void,
] => {
const [params, set] = createSignal(decode(window.location.search.slice(1)));

const setParams = (urlParams: URLParams) => {
window.history.replaceState(null, '', encode(set(urlParams), '?'));
};

return [params, setParams];
};
16 changes: 4 additions & 12 deletions test/NavLink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,14 @@ test('renders without props', () => {
test('renders correctly with required props', () => {
expect.assertions(1);
const rendered = render(() => <NavLink href="">x</NavLink>);
expect(rendered.container.innerHTML).toMatchInlineSnapshot(
'"<a href=\\"\\">x</a>"',
);
expect(rendered.container.innerHTML).toMatchInlineSnapshot('"<a href=\\"\\">x</a>"');
});

test.todo('renders "aria-current" attribute when location matches');
test.todo(
'does not render "aria-current" attribute when location does not match',
);
test.todo('does not render "aria-current" attribute when location does not match');
test.todo('renders "aria-current" attribute only on matching links');
test.todo('renders "aria-current" attribute when location deep matches');
test.todo(
'does not render "aria-current" attribute when location does not deep match',
);
test.todo(
'renders "aria-current" attribute after changing to a matching location',
);
test.todo('does not render "aria-current" attribute when location does not deep match');
test.todo('renders "aria-current" attribute after changing to a matching location');
test.todo('adds props as attributes on <a> element');
test.todo('does not add deepMatch prop as attribute on <a> element');
4 changes: 1 addition & 3 deletions test/Router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ test.todo('does not handle click with ctrl key');
test.todo('does not handle click with meta key');
test.todo('does not handle click with alt key');
test.todo('does not handle click with shift key');
test.todo(
'does not handle click when mouse button pressed is not the main button',
);
test.todo('does not handle click when mouse button pressed is not the main button');
test.todo('does not handle click when default already prevented');
test.todo('does not handle click when not on or inside a <a>');
test.todo('does not handle click when <a> has target attribute');
Expand Down
27 changes: 11 additions & 16 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,26 @@
import * as allExports from '../src/index';

const publicExports = [
['routeTo', 'function'],
['Router', 'function'],
['NavLink', 'function'],
['routeTo', 'Function'],
['Router', 'Function'],
['NavLink', 'Function'],
['useURLParams', 'Function'],
] as const;

for (const [name, type] of publicExports) {
test(`exports public "${name}" ${type}`, () => {
expect.assertions(2);
expect(name in allExports).toBe(true);
expect(typeof allExports[name]).toBe(type);
expect(allExports).toHaveProperty(name);
expect(Object.prototype.toString.call(allExports[name])).toBe(`[object ${type}]`);
});
}

test('does not export any private internals', () => {
expect.assertions(2);
const allPublicExportNames = [
...publicExports.map((x) => x[0]),
'default', // synthetic default created by TS at test runtime
];
const remainingExports = Object.keys(allExports);
expect(remainingExports.length >= publicExports.length).toBe(true);
for (const name of allPublicExportNames) {
remainingExports.splice(remainingExports.indexOf(name), 1);
}
expect(remainingExports).toHaveLength(0);
expect.assertions(5);
const allPublicExportNames = publicExports.map((x) => x[0]);
expect(allPublicExportNames).toHaveLength(Object.keys(allExports).length);
// eslint-disable-next-line guard-for-in
for (const name in allExports) expect(allPublicExportNames).toContain(name);
});

test('has no default export', () => {
Expand Down
64 changes: 64 additions & 0 deletions test/useURLParams.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/** @jest-environment jsdom */

import { useURLParams } from '../src';

function setURL(url: string) {
const oldLocation = window.location;
const location = new URL(url);
// @ts-expect-error - replace with mock
delete window.location;
// @ts-expect-error - simple mock
window.location = location;

return () => {
window.location = oldLocation;
};
}

// TODO: Break up tests + more and better tests

test('read works as expected', () => {
expect.assertions(3);
const reset1 = setURL('http://localhost/');
const [read1] = useURLParams();
expect(read1()).toEqual({});
reset1();
const reset2 = setURL(
'http://localhost/?a=1&b=2&c=3&c=4&d&c=null&c=undefined&c=0&c=false&c=true',
);
const [read2] = useURLParams();
expect(read2()).toEqual({
a: 1,
b: 2,
c: [3, 4, 'null', 'undefined', 0, false, true],
d: '',
});
reset2();
const reset3 = setURL('http://localhost/?ab_c=123.456&_def=1.00&-x-=-0.1&&&&');
const [read3] = useURLParams();
expect(read3()).toEqual({
ab_c: 123.456,
_def: 1,
'-x-': -0.1,
});
reset3();
});

test('set works as expected', () => {
expect.assertions(10);
const [read, set] = useURLParams();
expect(window.location.search).toBe('');
expect(read()).toEqual({});
set({ a: 1 });
expect(window.location.search).toBe('?a=1');
expect(read()).toEqual({ a: 1 });
set({ ...read(), b: 2 });
expect(window.location.search).toBe('?a=1&b=2');
expect(read()).toEqual({ a: 1, b: 2 });
set({ ...read(), a: undefined });
expect(window.location.search).toBe('?b=2');
expect(read()).toEqual({ a: undefined, b: 2 });
set({});
expect(window.location.search).toBe('');
expect(read()).toEqual({});
});

0 comments on commit de7ee3f

Please sign in to comment.