30 | );
31 | };
32 |
33 | export default QPTest;
34 |
--------------------------------------------------------------------------------
/src/interceptor.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | let incrementalId = 1;
4 |
5 | const interceptors = [];
6 |
7 | export const interceptRoute = (previousRoute, nextRoute) => {
8 | if (!interceptors.length) {
9 | return nextRoute;
10 | }
11 |
12 | return interceptors.reduceRight(
13 | (nextRoute, interceptor) => nextRoute === previousRoute
14 | ? nextRoute
15 | : interceptor.handlerFunction(previousRoute, nextRoute),
16 | nextRoute
17 | );
18 | };
19 |
20 | const get = (componentId) => interceptors.find(obj => obj.componentId === componentId) || null;
21 | const remove = (componentId) => {
22 | const index = interceptors.findIndex(obj => obj.componentId === componentId);
23 | if (index !== -1) {
24 | interceptors.splice(index, 1);
25 | }
26 | };
27 |
28 | export const useInterceptor = (handlerFunction) => {
29 | const [componentId] = React.useState(incrementalId++);
30 |
31 | let obj = get(componentId);
32 |
33 | if (!obj) {
34 | obj = {
35 | componentId,
36 | stop: () => remove(componentId),
37 | handlerFunction
38 | };
39 |
40 | interceptors.unshift(obj);
41 | }
42 |
43 | React.useEffect(() => () => obj.stop(), []);
44 |
45 | return obj.stop;
46 | };
47 |
--------------------------------------------------------------------------------
/src-docs/pages/en/01_overview.md:
--------------------------------------------------------------------------------
1 | # Overview
2 |
3 | - [Installation](#installation)
4 | - [Quick example](#quick-example)
5 |
6 | ## Installation
7 | Install this module and save it as a dependency:
8 |
9 | npm install --save hookrouter
10 |
11 |
12 | ## Quick example
13 |
14 | A quick example:
15 | ```jsx harmony
16 | import {useRoutes} from 'hookrouter';
17 |
18 | const routes = {
19 | '/': () => ,
20 | '/about': () => ,
21 | '/products': () => ,
22 | '/products/:id': ({id}) =>
23 | };
24 |
25 | const MyApp = () => {
26 | const routeResult = useRoutes(routes);
27 |
28 | return routeResult || ;
29 | }
30 | ```
31 | Routes are defined as an object. Keys are the routes, which are matched
32 | against the URL, the values need to be functions that are called when a route
33 | matches. You may define placeholders in your routes with `:something` which
34 | will be forwarded as props to your function calls so you can distribute them
35 | to your components.
36 |
37 | The hook will return whatever the route function returned, so you may also return
38 | strings, arrays, React fragments, null - whatever you like.
39 |
40 |
--------------------------------------------------------------------------------
/src/controlledInterceptor.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {useInterceptor} from "./interceptor";
3 | import {navigate} from "./router";
4 |
5 | /**
6 | * This is a controlled version of the interceptor which cancels any navigation intent
7 | * and hands control over it to your calling component.
8 | *
9 | * `interceptedPath` is initially `null` and will be set to the target path upon navigation.
10 | * `confirmNavigation` is the callback to be called to stop the interception and navigate to the last path.
11 | * `resetPath` is a callback that resets `interceptedPath` back to `null`.
12 | *
13 | * @returns {Array} [interceptedPath, confirmNavigation, resetPath]
14 | */
15 | export const useControlledInterceptor = () => {
16 | const [interceptedPath, setInterceptedPath] = React.useState(null);
17 |
18 | const interceptorFunction = React.useMemo(
19 | () => (currentPath, nextPath) => {
20 | setInterceptedPath(nextPath);
21 | return currentPath;
22 | },
23 | [setInterceptedPath]
24 | );
25 |
26 | const stopInterception = useInterceptor(interceptorFunction);
27 |
28 | const confirmNavigation = React.useMemo(
29 | () => () => {
30 | stopInterception();
31 | navigate(interceptedPath);
32 | },
33 | [stopInterception, interceptedPath]
34 | );
35 |
36 | const resetPath = React.useMemo(
37 | () => () => setInterceptedPath(null),
38 | [setInterceptedPath]
39 | );
40 |
41 | return [interceptedPath, confirmNavigation, resetPath, stopInterception];
42 | };
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hookrouter",
3 | "version": "1.2.3",
4 | "description": "A hook based router for React",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "test": "jest unittest/",
8 | "prepublishOnly": "cross-env NODE_ENV=production babel ./src --out-dir ./dist -s inline",
9 | "preTest": "babel ./src --out-dir ./dist -s inline && babel ./test/src --out-dir ./test/dist -s inline && npm run webpack",
10 | "webpack": "webpack ./test/dist/index.js -o ./test/dist/pack.js --devtool source-map",
11 | "testServer": "http-server ./test"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+ssh://git@github.com/Paratron/hookrouter.git"
16 | },
17 | "files": [
18 | "dist/*"
19 | ],
20 | "author": "Christian Engel ",
21 | "license": "ISC",
22 | "bugs": {
23 | "url": "https://github.com/Paratron/hookrouter/issues"
24 | },
25 | "homepage": "https://github.com/Paratron/hookrouter#readme",
26 | "peerDependencies": {
27 | "react": "^16.8.0"
28 | },
29 | "devDependencies": {
30 | "@babel/cli": "^7.2.3",
31 | "@babel/core": "^7.2.2",
32 | "@babel/preset-env": "^7.3.1",
33 | "@babel/preset-react": "^7.0.0",
34 | "cross-env": "5.2.0",
35 | "http-server": "0.11.1",
36 | "jest": "24.6.0",
37 | "node": "11.12.0",
38 | "react": "16.8.2",
39 | "react-dom": "16.8.5",
40 | "react-test-renderer": "16.8.5",
41 | "webpack": "4.29.6",
42 | "webpack-cli": "3.3.0"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Link.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {navigate, getBasepath} from "./router";
3 |
4 | /**
5 | * Accepts HTML `a`-tag properties, requiring `href` and optionally
6 | * `onClick`, which are appropriately wrapped to allow other
7 | * frameworks to be used for creating `hookrouter` navigatable links.
8 | *
9 | * If `onClick` is supplied, then the navigation will happen before
10 | * the supplied `onClick` action!
11 | *
12 | * @example
13 | *
14 | * <MyFrameworkLink what="ever" {...useLink({ href: '/' })}>
15 | * Link text
16 | * </MyFrameworkLink>
17 | *
18 | * @param {Object} props Requires `href`. `onClick` is optional.
19 | */
20 | export const setLinkProps = (props) => {
21 | const onClick = (e) => {
22 | if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && props.target !== "_blank")) {
23 | e.preventDefault(); // prevent the link from actually navigating
24 | navigate(e.currentTarget.href);
25 | }
26 |
27 | if (props.onClick) {
28 | props.onClick(e);
29 | }
30 | };
31 | const href =
32 | props.href.substr(0, 1) === '/'
33 | ? getBasepath() + props.href
34 | : props.href;
35 |
36 | return {...props, href, onClick};
37 | };
38 |
39 | /**
40 | * Accepts standard HTML `a`-tag properties. `href` and, optionally,
41 | * `onClick` are used to create links that work with `hookrouter`.
42 | *
43 | * @example
44 | *
45 | * <A href="/" target="_blank">
46 | * Home
47 | * </A>
48 | *
49 | * @param {Object} props Requires `href`. `onClick` is optional
50 | */
51 | export const A = (props) => ;
52 |
--------------------------------------------------------------------------------
/src-docs/pages/en/05_serverside-rendering.md:
--------------------------------------------------------------------------------
1 | # Serverside rendering
2 |
3 | - [Setting the path](#setting-the-path)
4 | - [Setting query parameters](#setting-query-parameters)
5 | - [Handling redirects](#handling-redirects)
6 | - [Handling window title updates](#handling-window-title-updates)
7 |
8 | ## Setting the path
9 | Use the `setPath()` function before you start rendering the application to manually
10 | define a routing path beforehand.
11 |
12 | ```jsx
13 | const React = require('react');
14 | const {renderToString} = require('react-dom/server');
15 | const hookrouter = require('../../dist');
16 |
17 | const path = '/product';
18 |
19 | hookrouter.setPath(path);
20 |
21 | import App from './App';
22 |
23 | const result = renderToString();
24 |
25 | console.log(`Rendering with path "${path}"`);
26 | console.log(result);
27 | ```
28 |
29 | ## Setting query parameters
30 | If you want to communicate query parameters to the app, call the `setQueryParams()`
31 | function, passing an object of query parameters into the app. Call this before or
32 | after `setPath()` but also before rendering the actual application.
33 |
34 | If your app might modify the query parameters during rendering, you can retrieve them
35 | afterwards using `getQueryParams()`, which returns an object, or `getQueryParamsString()`
36 | which returns the serialized string.
37 |
38 | ## Handling redirects
39 | If your app might perform any redirects during rendering, you can retrieve the updated
40 | path after rendering by calling `getPath()`.
41 |
42 | ## Handling window title updates
43 | If your app might have set a window title during rendering, you can retrieve it by calling
44 | `getTitle()`.
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Hook Router
2 |
3 | The modern alternative to react-router.
4 |
5 | Tested from `React 16.8.1` upwards.
6 |
7 | ## How to install
8 | Well, this is straightforward:
9 |
10 | npm i hookrouter
11 |
12 | ## Typescript
13 | This project is not and will not be written in typescript.
14 |
15 | Thanks to the github user [@mcaneris](https://github.com/mcaneris), you can install types via:
16 |
17 | npm i @types/hookrouter
18 |
19 | I did not check if those types are correct nor will I keep them up to date with future releases.
20 |
21 |
22 | ## Documentation
23 | Detailed documentation about how to use hookrouter can be [found here](https://github.com/Paratron/hookrouter/blob/master/src-docs/pages/en/README.md)
24 |
25 | ## A quick example
26 | ```jsx harmony
27 | import {useRoutes} from 'hookrouter';
28 |
29 | const routes = {
30 | '/': () => ,
31 | '/about': () => ,
32 | '/products': () => ,
33 | '/products/:id': ({id}) =>
34 | };
35 |
36 | const MyApp = () => {
37 | const routeResult = useRoutes(routes);
38 |
39 | return routeResult || ;
40 | }
41 | ```
42 | Routes are defined as an object. Keys are the routes, which are matched
43 | against the URL, the values need to be functions that are called when a route
44 | matches. You may define placeholders in your routes with `:something` which
45 | will be forwarded as props to your function calls so you can distribute them
46 | to your components.
47 |
48 | The hook will return whatever the route function returned, so you may also return
49 | strings, arrays, React fragments, null - whatever you like.
50 |
--------------------------------------------------------------------------------
/src-docs/pages/en/README.md:
--------------------------------------------------------------------------------
1 | # React Hook Router Documentation
2 |
3 | - [Overview](./01_overview.md)
4 | - [Installation](./01_overview.md#installation)
5 | - [Quick example](./01_overview.md#quick-example)
6 | - [Routing](./02_routing.md)
7 | - [Defining routes](./02_routing.md#defining-routes)
8 | - [URL parameters](./02_routing.md#url-parameters)
9 | - [Nested routing](./02_routing.md#nested-routing)
10 | - [Can I use async functions?](./02_routing.md#can-i-use-async-functions)
11 | - [Lazy loading components](./02_routing.md#lazy-loading-components)
12 | - [Passing additional data to route functions](./02_routing.md#passing-additional-data-to-route-functions)
13 | - [Navigation](./03_navigation.md)
14 | - [Programmatic navigation](./03_navigation.md#programmatic-navigation)
15 | - [Redirects](./03_navigation.md#redirects)
16 | - [Using the link component](./03_navigation.md#using-the-link-component)
17 | - [Creating custom link components](./03_navigation.md#creating-custom-link-components)
18 | - [Intercepting navigation intents](./03_navigation.md#intercepting-navigation-intents)
19 | - [Controlled interceptors](./03_navigation.md#controlled-interceptors)
20 | - [Setting a base path](./03_navigation.md#setting-a-base-path)
21 | - [Other Features](./04_other-features.md)
22 | - [Setting the window title](./04_other-features.md#setting-the-window-title)
23 | - [Using query parameters](./04_other-features.md#using-query-parameters)
24 | - [Using the URI path](./04_other-features.md#using-the-uri-path)
25 | - [Serverside Rendering](./05_serverside-rendering.md)
26 | - [Setting the path](./05_serverside-rendering.md#setting-the-path)
27 | - [Setting query parameters](./05_serverside-rendering.md#setting-query-parameters)
28 | - [Handling redirects](./05_serverside-rendering.md#handling-redirects)
29 | - [Handling window title updates](./05_serverside-rendering.md#handling-window-title-updates)
30 |
--------------------------------------------------------------------------------
/src/queryParams.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import isNode from './isNode';
3 |
4 | const queryParamListeners = [];
5 | let queryParamObject = {};
6 |
7 | export const setQueryParams = (inObj, replace = false) => {
8 | if(!(inObj instanceof Object)){
9 | throw new Error('Object required');
10 | }
11 | if(replace){
12 | queryParamObject = inObj;
13 | } else {
14 | Object.assign(queryParamObject, inObj);
15 | }
16 | const now = Date.now();
17 | queryParamListeners.forEach(cb => cb(now));
18 | if (!isNode) {
19 | const qs = '?' + objectToQueryString(queryParamObject);
20 | if(qs === location.search) {
21 | return;
22 | }
23 | history.replaceState(null, null, location.pathname + (qs !== '?' ? qs : ''));
24 | }
25 | };
26 |
27 | export const getQueryParams = () => Object.assign({}, queryParamObject);
28 |
29 | /**
30 | * This takes an URL query string and converts it into a javascript object.
31 | * @param {string} inStr
32 | * @return {object}
33 | */
34 | const queryStringToObject = (inStr) => {
35 | const p = new URLSearchParams(inStr);
36 | let result = {};
37 | for (let param of p) {
38 | result[param[0]] = param[1];
39 | }
40 | return result;
41 | };
42 |
43 | /**
44 | * This takes a javascript object and turns it into a URL query string.
45 | * @param {object} inObj
46 | * @return {string}
47 | */
48 | const objectToQueryString = (inObj) => {
49 | const qs = new URLSearchParams();
50 | Object.entries(inObj).forEach(([key, value]) => value !== undefined ? qs.append(key, value) : null);
51 | return qs.toString();
52 | };
53 |
54 | if(!isNode){
55 | queryParamObject = queryStringToObject(location.search.substr(1));
56 | }
57 |
58 | /**
59 | * This hook returns the currently set query parameters as object and offers a setter function
60 | * to set a new query string.
61 | *
62 | * All components that are hooked to the query parameters will get updated if they change.
63 | * Query params can also be updated along with the path, by calling `navigate(url, queryParams)`.
64 | *
65 | * @returns {array} [queryParamObject, setQueryParams]
66 | */
67 | export const useQueryParams = () => {
68 | const setUpdate = React.useState(0)[1];
69 |
70 | React.useEffect(() => {
71 | queryParamListeners.push(setUpdate);
72 |
73 | return () => {
74 | const index = queryParamListeners.indexOf(setUpdate);
75 | if (index === -1) {
76 | return;
77 | }
78 | queryParamListeners.splice(index, 1);
79 | };
80 | }, [setUpdate]);
81 |
82 | return [queryParamObject, setQueryParams];
83 | };
84 |
--------------------------------------------------------------------------------
/unittest/Link.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from 'react-test-renderer';
3 | import {navigate, getBasepath} from "../src/router";
4 | import {A, setLinkProps} from "../src";
5 |
6 | // allow us to mock navigate and getBasepath
7 | jest.mock("../src/router");
8 |
9 | describe('Link.js', () => {
10 |
11 | describe('useLink', () => {
12 |
13 | test('throws error when href not supplied', () => {
14 | expect(() => setLinkProps()).toThrow();
15 | expect(() => setLinkProps({onClick: () => null})).toThrow();
16 | });
17 |
18 | test('provides onClick that performs navigation', () => {
19 | const {href, onClick} = setLinkProps({href: "test1"});
20 |
21 | expect(href).toBe("test1");
22 | expect(onClick).toBeInstanceOf(Function);
23 |
24 | const e = {
25 | preventDefault: jest.fn(),
26 | currentTarget: {
27 | href: "onClick1"
28 | }
29 | };
30 |
31 | onClick(e);
32 |
33 | expect(e.preventDefault).toHaveBeenCalledTimes(1);
34 | expect(navigate).toHaveBeenCalledWith("onClick1");
35 | });
36 |
37 | test('wraps onClick and triggers wrapped onClick with event', () => {
38 | const wrappedOnClick = jest.fn();
39 | const {href, onClick} = setLinkProps({href: "test2", onClick: wrappedOnClick});
40 |
41 | expect(href).toBe("test2");
42 | expect(onClick).toBeInstanceOf(Function);
43 |
44 | const e = {
45 | preventDefault: jest.fn(),
46 | currentTarget: {
47 | href: "onClick2"
48 | }
49 | };
50 |
51 | onClick(e);
52 |
53 | expect(e.preventDefault).toHaveBeenCalledTimes(1);
54 | expect(navigate).toHaveBeenCalledWith("onClick2");
55 | expect(wrappedOnClick).toHaveBeenCalledWith(e);
56 | });
57 |
58 | test('uses getBasepath() when href starts with /', () => {
59 | getBasepath.mockReturnValue("/test3");
60 |
61 | const {href, onClick} = setLinkProps({href: "/test-basepath"});
62 |
63 | expect(href).toBe("/test3/test-basepath");
64 | expect(onClick).toBeInstanceOf(Function);
65 | });
66 |
67 | });
68 |
69 | describe('A', () => {
70 |
71 | test('renders correctly with href', () => {
72 | getBasepath.mockReturnValue("");
73 |
74 | const tree = renderer
75 | .create(hookrouter)
76 | .toJSON();
77 |
78 | expect(tree).toMatchSnapshot();
79 | });
80 |
81 | test('renders correctly with href and onClick', () => {
82 | getBasepath.mockReturnValue("");
83 |
84 | const tree = renderer
85 | .create( null}>hookrouter)
86 | .toJSON();
87 |
88 | expect(tree).toMatchSnapshot();
89 | });
90 |
91 | test('renders correctly with href, onClick, and target', () => {
92 | getBasepath.mockReturnValue("");
93 |
94 | const tree = renderer
95 | .create( null} target="_blank">hookrouter)
96 | .toJSON();
97 |
98 | expect(tree).toMatchSnapshot();
99 | });
100 |
101 | test('renders correctly with href, onClick, and target plus basepath', () => {
102 | getBasepath.mockReturnValue("/test4");
103 |
104 | const tree = renderer
105 | .create( null} target="_blank">hookrouter)
106 | .toJSON();
107 |
108 | expect(tree).toMatchSnapshot();
109 | });
110 |
111 | });
112 |
113 | });
114 |
--------------------------------------------------------------------------------
/src-docs/pages/en/04_other-features.md:
--------------------------------------------------------------------------------
1 | # Other helpful features
2 |
3 | - [Setting the window title](#setting-the-window-title)
4 | - [Using query parameters](#using-query-parameters)
5 |
6 | ## Setting the window title
7 | When the user is navigating through your application you may wish to update the window
8 | title according to the currently rendered route. To do that, I implemented a hook `useTitle()`.
9 |
10 | ```jsx
11 | const AboutPage = () => {
12 | useTitle('About me');
13 |
14 | return (
15 |
16 | ...
17 |
18 | );
19 | }
20 | ```
21 |
22 | When the component using the hook gets mounted, the window title will be set. If the component
23 | gets unmounted, the previous title will be restored.
24 |
25 | If you want to utilize the window title in an SSR environment, call `getTitle` after your
26 | application rendering is done.
27 |
28 | ## Using query parameters
29 | In some cases it might be necessary to utilize query / search parameters to sync some application
30 | state with the URL.
31 |
32 | You might for example mirror a search string in the URL to enable the user to bookmark that search
33 | or store filter settings for an overview page in the URL.
34 |
35 | I implemented the `useQueryParams()` hook to do that. It automatically serializes and deserializes
36 | query parameters from the URL. Even more: if you decide to update the query parameters, all components
37 | using the hook will be re-rendered and can evaluate the new data.
38 |
39 | Setting query params does _not_ trigger a navigation intent. Query parameters are not used for
40 | routing in hookrouter.
41 |
42 | ```jsx
43 | const SearchWidget = ({onSearch}) => {
44 | const [queryParams, setQueryParams] = useQueryParams();
45 |
46 | const {
47 | // Use object destructuring and a default value
48 | // if the param is not yet present in the URL.
49 | q = ''
50 | } = queryParams;
51 |
52 | const [searchBuffer, setSearchBuffer] = React.useState(q);
53 |
54 | const searchHandler = () => {
55 | setQueryParams({q: searchBuffer});
56 | }
57 |
58 | return (
59 |
99 | {Object
100 | .entries(products)
101 | .sort(
102 | (a, b) => sort === 'asc'
103 | ? a.title > b.title
104 | ? 1
105 | : -1
106 | : a.title > b.title
107 | ? -1
108 | : 1
109 | )
110 | // Note: the link uses the setLinkProps method, but you should prefer using
111 | // the hookrouter 'A' component if you are not using a framework that
112 | // requires href / onClick to be provided to it
113 | .map(([id, title]) => (
114 |
175 |
176 |
186 |
187 |
188 |
189 |
190 | );
191 | };
192 |
193 | export default App;
194 |
--------------------------------------------------------------------------------
/src-docs/pages/en/02_routing.md:
--------------------------------------------------------------------------------
1 | # Routing
2 |
3 | - [Defining routes](#defining-routes)
4 | - [URL parameters](#url-parameters)
5 | - [Nested routing](#nested-routing)
6 | - [Can I use async functions?](#can-i-use-async-functions)
7 | - [Lazy loading components](#lazy-loading-components)
8 | - [Passing additional data to route functions](#passing-additional-data-to-route-functions)
9 |
10 | ## Defining routes
11 | The `useRoutes()` hook consumes an object where the keys define paths and the values are functions to be called when
12 | a path matches. The router will try to match the paths one after another and will stop evaluating after a match has
13 | been found.
14 |
15 | ```jsx
16 | const routes = {
17 | '/': () => ,
18 | '/about': () =>
19 | }
20 | ```
21 |
22 | > __Heads up:__
23 | > Its recommendable to define the routes object _outside_ of your components. If you define it inside a component, the whole object will be re-created upon every render.
24 |
25 | The callback functions are important so your components will only be created when a route matched and that function
26 | got called.
27 |
28 | The router is built in a way that it not cares about what your route function returns. In most cases that would be a
29 | React component, but you may also return strings, numbers or anything.
30 |
31 | If no match could be made, the route result will be `null` so you can easily display fallback content.
32 |
33 | ```jsx
34 | import React from 'react';
35 | import {useRoutes} from 'hookrouter';
36 | import routes from './routes';
37 | import {NotFoundPage} from './pages';
38 |
39 | const MyApp = () => {
40 | const match = useRoutes(routes);
41 |
42 | return match || ;
43 | }
44 | ```
45 |
46 | > __Important to know__
47 | > Hookrouter will cache the route results. Because of that, your route result functions should be pure (side effect free). This means they should only rely on the parameters that are passed into the functions. If you want to pass additional data, [there is a pattern for that](#passing-additional-data-to-route-functions).
48 |
49 | ## URL parameters
50 | Your paths may contain parts that should be consumed as parameters for your application. For example, a product page
51 | route would contain the ID of a product.
52 |
53 | To fetch these parameters, you can define named placeholders in your routes. They start with a colon `:` and all
54 | characters up until the next slash `/` will be captured. All named parameters will be forwarded to your route result
55 | function as a combined object.
56 |
57 | ```jsx
58 | const routes = {
59 | '/product/:id/:variant': ({id, variant}) =>
60 | }
61 | ```
62 |
63 | ## Nested routing
64 | You may nest routes so the sub route calls continue to work with a sub part of the
65 | url.
66 |
67 | ### Example
68 | This is your main application:
69 | ```jsx harmony
70 | import {useRoutes, A} from 'hookrouter';
71 |
72 | const routes = {
73 | '/': () => ,
74 | '/about*': () =>
75 | };
76 |
77 | const MyApp = () => {
78 | const routeResult = useRoutes(routes);
79 |
80 | return (
81 |
85 | );
86 | }
87 | ```
88 | The asterisk `*` at the end of the route indicates that the URL will continue
89 | but the later part is handled somewhere else. If the router notices an asterisk
90 | at the end, it will forward the remaining part of the URL to child routers.
91 |
92 | See whats done now inside the `` component:
93 |
94 | ```jsx harmony
95 | import {useRoutes, A} from 'hookrouter';
96 |
97 | const routes = {
98 | '/people': () => 'We are happy people',
99 | '/company': () => 'Our company is nice'
100 | };
101 |
102 | const AboutArea = () => {
103 | const routeResult = useRoutes(routes);
104 |
105 | return (
106 |
111 | );
112 | }
113 | ```
114 |
115 | ## Can I use async functions?
116 | In short: no. You dont even want to use async functions! Your UI needs to be very responsive to any action you user
117 | performs. So routing is absolutely synchronous. But that doesnt mean you cannot perform async operations afterwards.
118 | You would use the router to display a loading placeholder instead that gets replaced whe the real content is available.
119 |
120 | ## Lazy loading components
121 |
122 | Lazy loading and code splitting is very simple with hookrouter:
123 |
124 | ```jsx
125 | import React from 'react';
126 |
127 | const ProductPage = React.lazy(() => import('./pages/Product'));
128 |
129 | const routes = {
130 | 'product/:id': ({id}) =>
131 | }
132 | ```
133 |
134 | While this works, it would result in a blank paage until the missing code is fetched. I recommend wrapping the lazy
135 | component into a `Suspense` component to display fallback content until the code is loaded.
136 |
137 |
138 | ## Passing additional data to route functions
139 | In our nested routes example I demonstrated how you can split routing for sub-parts of a bigger module of your
140 | application further down in the component tree. I also mentioned the fact that hookrouter does not exactly care what
141 | you return from your route functions. We can utilize that fact to optimize data fetching for our sub modules.
142 |
143 | Imagine a product page that is broken down into separate sub parts. There is a general information page, a page about
144 | technical details and one to give you buying options. Lets say all three of them utilize the same product object. So we
145 | are going to fetch that object in our central `` component:
146 |
147 | ```jsx
148 |
149 | const routes = {
150 | '/' => () => (product) => ,
151 | '/details': () => (product) => ,
152 | '/buy': () => (product) =>
153 | '/buy/:variant': ({variant}) => (product) =>
154 | };
155 |
156 | const ProductPage = ({id}) => {
157 | const [productObj, status] = useProduct(id);
158 | const match = useRoutes(routes);
159 |
160 | if(!match){
161 | return 'Page not found';
162 | }
163 |
164 | if(status.loading){
165 | return 'Loading product...';
166 | }
167 |
168 | return match(productObj);
169 | };
170 | ```
171 |
172 | You can see: the route functions return functions themselves. We can feed additional data to those and have it available
173 | together with any url parameters when it comes to actually render a component. This is a mighty pattern that lets you
174 | fetch data for your components that would not have been available through URL parameters alone.
175 |
--------------------------------------------------------------------------------
/src-docs/pages/en/03_navigation.md:
--------------------------------------------------------------------------------
1 | # Navigation
2 |
3 | - [Programmatic navigation](#programmatic-navigation)
4 | - [Redirects](#redirects)
5 | - [Using the link component](#using-the-link-component)
6 | - [Creating custom link components](#creating-custom-link-components)
7 | - [Intercepting navigation intents](#intercepting-navigation-intents)
8 | - [Controlled interceptors](#controlled-interceptors)
9 | - [Setting a base path](#setting-a-base-path)
10 |
11 | ## Programmatic navigation
12 | If you want to send your user somewhere, you can call the `navigate(url, [replace], [queryParams])` function from the
13 | hookrouter package. You pass an URL (both relative or absolute) and the navigation will happen. After the navigation,
14 | all previous matches will be re-evaluated if they are valid anymore or if some components need to be swapped.
15 |
16 | ```jsx
17 | navigate('/about');
18 | ```
19 |
20 | By default, every call to `navigate()` is a forward navigation. That means a new entry in the browsing history of
21 | the user is created as if they visited a new page. Because of that, the user can click the back-button in their browser
22 | to get back to previous pages.
23 |
24 | However, in some cases you need a different behaviour: there may be pages that get invalid if you navigate back to them.
25 | In that case you can do a replace navigation which erases the current history entry and replaces it with a new one.
26 | Set the second argument to `true` to achieve that.
27 |
28 | ```jsx
29 | navigate('/confirmPage', true);
30 | ```
31 |
32 | As an example, the `useRedirect()` hook uses a replace navigation internally.
33 |
34 | The third and last argument allows you to set query string parameters with your navigation operation. You could encode
35 | them into your URL manually but often it is easier to pass an object, here.
36 |
37 | ```jsx
38 | // These are the same
39 | navigate('/test?a=hello&b=world');
40 | navigate('/test', false, {a: 'hello', b: 'world'});
41 | ```
42 |
43 | Please note that if you do a navigation with query parameters, old query parameters will be dropped. This is unlike
44 | the default behavior of `useQueryParams`, where parameters will be _appended_. If you would like your query parameters
45 | to be appended upon navigation, pass `false` as fourth argument (`replaceQueryParams`): `navigate('/test', false, {...}, false)`.
46 |
47 | Also note that if you do a navigation with query parameters already encoded into the URI, `useQueryParams` hooks won't be updated!
48 |
49 | ## Redirects
50 | A redirect automatically forwards the user to a target path, if its source path matches.
51 |
52 | Redirects trigger replacement navigation intents, which means there will remain
53 | one entry in the navigation history. If a forward from `/test` to `/other` happens,
54 | `/test` will not appear in the browsing history.
55 |
56 |
57 | ```jsx harmony
58 | import {useRoutes, useRedirect} from 'hookrouter';
59 |
60 | const routes = {
61 | '/greeting': () => 'Nice to meat you 🤤 ',
62 | };
63 |
64 | const MyApp = () => {
65 | useRedirect('/', '/greeting');
66 | const routeResult = useRoutes(routes);
67 |
68 | return routeResult || 'Not found';
69 | }
70 | ```
71 | Rule of thumb: apply the redirect right before you use the routing and everything
72 | is fine ;)
73 |
74 | You can pass an object of query parameters as third argument to the `useRedirect()` function.
75 |
76 |
77 | ## Using the Link component
78 | ```jsx harmony
79 | import {useRoutes, A} from 'hookrouter';
80 |
81 | const routes = {
82 | '/': () => ,
83 | '/products/:id': ({id}) =>
84 | };
85 |
86 | const MyApp = () => {
87 | const routeResult = useRoutes(routes);
88 |
89 | return (
90 |
94 | );
95 | }
96 | ```
97 | The `A` component works internally with a default `a` HTML tag. It will forward
98 | all props to it, except an `onClick` function, which will be wrapped by the component,
99 | since it intercepts the click event, stops the default behavior and pushes the
100 | URL on the history stack, instead.
101 |
102 |
103 | ## Creating custom link components
104 | In case you need more control about link components and want to roll out your own, you can use the helper
105 | function `setLinkProps()` provided by hookrouter.
106 |
107 | ```jsx
108 | const MyLinkButton = (props) => {
109 |
110 | }
111 | ```
112 |
113 | `setLinkProps()` requires an object as argument with at least the `href` property set. The function will return
114 | all passed props as-is but might modify the `href`. If also a `onClick` handler is found in the passed props object,
115 | that function will be wrapped and called after an internal navigation intent has been triggered.
116 |
117 | The same function is used internally for creating the Link component.
118 |
119 | ## Intercepting navigation intents
120 | Sometimes it is necessary to interfere with the navigation intents of the user, based on certain conditions.
121 | You may want to ask before a user leaves a page with a half-filled form to prevent data loss. Or you have split a
122 | registration progress across several routes, let the user navigate there but not away from a certain base part of the
123 | URL. Or you want to forbit certain routes for some users. The scenarios may vary.
124 |
125 | Introducing interceptors:
126 |
127 | ```jsx
128 | const interceptFunction = (currentPath, nextPath) => {
129 | if(confirm('Do you want to leave?')){
130 | return nextPath;
131 | }
132 | return currentPath;
133 | }
134 |
135 | const GuardedForm = () => {
136 | const stopInterceptor = useInterceptor(interceptFunction);
137 |
138 | const handleSubmit = () => {
139 | saveData();
140 | stopInterceptor();
141 | navigate('/success');
142 | }
143 |
144 | return (
145 |
148 | );
149 | }
150 | ```
151 |
152 | The interceptor hook gets called and the interceptor function enabled when the component containing the hook gets
153 | rendered the first time.
154 |
155 | From the point of registration onwards, all navigation intents call the interceptor beforehands. The interceptor
156 | function gets passed the current and next path and can decide what to do. Whatever path is returned from the function
157 | will be the target of the navigation intent. If the current path is returned, no navigation happens at all.
158 |
159 | When the component that created the interceptor gets unmounted, the interceptor will be stopped automatically.
160 |
161 | Interceptors can be stacked, so when a sub component registers an interceptor while the parent component already did so,
162 | they will be called in a chain with the last registered interceptor being called first. If one interceptor returns
163 | the current path, no other interceptors down the chain will be asked anymore.
164 |
165 | You can manually stop the intercepor as well. The hook will return a function that cancels the interceptor.
166 |
167 | ## Controlled interceptors
168 | While the interceptor gives very granular control about navigation intents, it requires a synchronous
169 | response to the question if a navigation intent should be stopped, or not.
170 |
171 | I have included a second interceptor hook which based on the first one hands the control over to
172 | your react component to make use of custom UI constructs to handle the intent.
173 |
174 | ```jsx
175 | const GuardedForm = () => {
176 | const [nextPath, confirmNavigation, resetPath, stopInterception] = useControlledInterceptor();
177 |
178 | const handleSubmit = () => {
179 | saveData();
180 | stopInterception();
181 | navigate('/success');
182 | };
183 |
184 | return (
185 |
186 | {nextPath && (
187 |
191 | ) }
192 |
195 |
196 | );
197 | }
198 | ```
199 |
200 | ## Setting a base path
201 | You can tell the router to ignore a certain path at the beginning of your URLs, if your app is not hosted on the root
202 | folder of a domain.
203 |
204 | Call the `setBasepath()` function before you start rendering your app containing your routers. After calling this function,
205 | the defined base path will be the root of your application, so when you call `navigate('/about')`,
206 | or place a Link with ``, they will actually lead to `[basepath]/about`.
207 |
208 | ```jsx
209 | import React from 'react';
210 | import ReactDOM from 'react-dom';
211 | import {setBasepath} from "hookrouter";
212 |
213 | setBasepath('/basepath');
214 |
215 | import App from './App';
216 |
217 | ReactDOM.render(, document.getElementById('root'));
218 | ```
219 |
--------------------------------------------------------------------------------
/src/router.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import isNode from './isNode';
3 | import {setQueryParams} from './queryParams';
4 | import {interceptRoute} from './interceptor';
5 |
6 | let preparedRoutes = {};
7 | let stack = {};
8 | let componentId = 1;
9 | let currentPath = isNode ? '' : location.pathname;
10 | let basePath = '';
11 | let basePathRegEx = null;
12 | const pathUpdaters = [];
13 |
14 | /**
15 | * Will define a base path that will be utilized in your routing and navigation.
16 | * To be called _before_ any routing or navigation happens.
17 | * @param {string} inBasepath
18 | */
19 | export const setBasepath = (inBasepath) => {
20 | basePath = inBasepath;
21 | basePathRegEx = new RegExp('^' + basePath);
22 | };
23 |
24 | /**
25 | * Returns the currently used base path.
26 | * @returns {string}
27 | */
28 | export const getBasepath = () => basePath;
29 |
30 | const resolvePath = (inPath) => {
31 | if (isNode) {
32 | const url = require('url');
33 | return url.resolve(currentPath, inPath);
34 | }
35 |
36 | const current = new URL(currentPath, location.href);
37 | const resolved = new URL(inPath, current);
38 | return resolved.pathname;
39 | };
40 |
41 | export const ParentContext = React.createContext(null);
42 |
43 | /**
44 | * Pass a route string to this function to receive a regular expression.
45 | * The transformation will be cached and if you pass the same route a second
46 | * time, the cached regex will be returned.
47 | * @param {string} inRoute
48 | * @returns {Array} [RegExp, propList]
49 | */
50 | const prepareRoute = (inRoute) => {
51 | if (preparedRoutes[inRoute]) {
52 | return preparedRoutes[inRoute];
53 | }
54 |
55 | const preparedRoute = [
56 | new RegExp(`${inRoute.substr(0, 1) === '*' ? '' : '^'}${inRoute.replace(/:[a-zA-Z]+/g, '([^/]+)').replace(/\*/g, '')}${inRoute.substr(-1,) === '*' ? '' : '$'}`)
57 | ];
58 |
59 | const propList = inRoute.match(/:[a-zA-Z]+/g);
60 | preparedRoute.push(
61 | propList
62 | ? propList.map(paramName => paramName.substr(1))
63 | : []
64 | );
65 |
66 | preparedRoutes[inRoute] = preparedRoute;
67 | return preparedRoute;
68 | };
69 |
70 | /**
71 | * Virtually navigates the browser to the given URL and re-processes all routers.
72 | * @param {string} url The URL to navigate to. Do not mix adding GET params here and using the `getParams` argument.
73 | * @param {boolean} [replace=false] Should the navigation be done with a history replace to prevent back navigation by the user
74 | * @param {object} [queryParams] Key/Value pairs to convert into get parameters to be appended to the URL.
75 | * @param {boolean} [replaceQueryParams=true] Should existing query parameters be carried over, or dropped (replaced)?
76 | */
77 | export const navigate = (url, replace = false, queryParams = null, replaceQueryParams = true) => {
78 | url = interceptRoute(currentPath, resolvePath(url));
79 |
80 | if (!url || url === currentPath) {
81 | return;
82 | }
83 |
84 | currentPath = url;
85 |
86 | if (isNode) {
87 | setPath(url);
88 | processStack();
89 | updatePathHooks();
90 | return;
91 | }
92 |
93 | const finalURL = basePathRegEx
94 | ? url.match(basePathRegEx)
95 | ? url
96 | : basePath + url
97 | :
98 | url;
99 |
100 | window.history[`${replace ? 'replace' : 'push'}State`](null, null, finalURL);
101 | processStack();
102 | updatePathHooks();
103 |
104 | if (queryParams) {
105 | setQueryParams(queryParams, replaceQueryParams);
106 | }
107 | };
108 |
109 | let customPath = '/';
110 | /**
111 | * Enables you to manually set the path from outside in a nodeJS environment, where window.history is not available.
112 | * @param {string} inPath
113 | */
114 | export const setPath = (inPath) => {
115 | const url = require('url');
116 | customPath = url.resolve(customPath, inPath);
117 | };
118 |
119 | /**
120 | * Returns the current path of the router.
121 | * @returns {string}
122 | */
123 | export const getPath = () => customPath;
124 |
125 | /**
126 | * This hook returns the currently used URI.
127 | * Works in a browser context as well as for SSR.
128 | *
129 | * _Heads up:_ This will make your component render on every navigation unless you set this hook to passive!
130 | * @param {boolean} [active=true] Will update the component upon path changes. Set to false to only retrieve the path, once.
131 | * @param {boolean} [withBasepath=false] Should the base path be left at the beginning of the URI?
132 | * @returns {string}
133 | */
134 | export const usePath = (active = true, withBasepath = false) => {
135 | const [, setUpdate] = React.useState(0);
136 |
137 | React.useEffect(() => {
138 | if (!active) {
139 | return;
140 | }
141 |
142 | pathUpdaters.push(setUpdate);
143 | return () => {
144 | const index = pathUpdaters.indexOf(setUpdate);
145 | if (index !== -1) {
146 | pathUpdaters.splice(index, 1);
147 | }
148 | };
149 | }, [setUpdate]);
150 |
151 | return withBasepath ? currentPath : currentPath.replace(basePathRegEx, '');
152 | };
153 |
154 | /**
155 | * Render all components that use path hooks.
156 | */
157 | const updatePathHooks = () => {
158 | const now = Date.now();
159 | pathUpdaters.forEach(cb => cb(now));
160 | };
161 |
162 | /**
163 | * Called from within the router. This returns either the current windows url path
164 | * or a already reduced path, if a parent router has already matched with a finishing
165 | * wildcard before.
166 | * @param {string} [parentRouterId]
167 | * @returns {string}
168 | */
169 | export const getWorkingPath = (parentRouterId) => {
170 | if (!parentRouterId) {
171 | return isNode ? customPath : window.location.pathname.replace(basePathRegEx, '') || '/';
172 | }
173 | const stackEntry = stack[parentRouterId];
174 | if (!stackEntry) {
175 | throw 'wth';
176 | }
177 |
178 | return stackEntry.reducedPath !== null ? stackEntry.reducedPath || '/' : window.location.pathname;
179 | };
180 |
181 | const processStack = () => Object.values(stack).forEach(process);
182 |
183 | /**
184 | * This function takes two objects and compares if they have the same
185 | * keys and their keys have the same values assigned, so the objects are
186 | * basically the same.
187 | * @param {object} objA
188 | * @param {object} objB
189 | * @return {boolean}
190 | */
191 | const objectsEqual = (objA, objB) => {
192 | const objAKeys = Object.keys(objA);
193 | const objBKeys = Object.keys(objB);
194 |
195 | const valueIsEqual = key => objB.hasOwnProperty(key) && objA[key] === objB[key];
196 |
197 | return (
198 | objAKeys.length === objBKeys.length
199 | && objAKeys.every(valueIsEqual)
200 | );
201 | };
202 |
203 | if (!isNode) {
204 | window.addEventListener('popstate', (e) => {
205 | const nextPath = interceptRoute(currentPath, location.pathname);
206 |
207 | if (!nextPath || nextPath === currentPath) {
208 | e.preventDefault();
209 | e.stopPropagation();
210 | history.pushState(null, null, currentPath);
211 | return;
212 | }
213 |
214 | currentPath = nextPath;
215 |
216 | if (nextPath !== location.pathname) {
217 | history.replaceState(null, null, nextPath);
218 | }
219 | processStack();
220 | updatePathHooks();
221 | });
222 | }
223 |
224 | const emptyFunc = () => null;
225 |
226 | /**
227 | * This will calculate the match of a given router.
228 | * @param {object} stackObj
229 | * @param {boolean} [directCall] If its not a direct call, the process function might trigger a component render.
230 | */
231 | const process = (stackObj, directCall) => {
232 | const {
233 | routerId,
234 | parentRouterId,
235 | routes,
236 | setUpdate,
237 | resultFunc,
238 | resultProps,
239 | reducedPath: previousReducedPath
240 | } = stackObj;
241 |
242 | const currentPath = getWorkingPath(parentRouterId);
243 | let route = null;
244 | let targetFunction = null;
245 | let targetProps = null;
246 | let reducedPath = null;
247 | let anyMatched = false;
248 |
249 | for (let i = 0; i < routes.length; i++) {
250 | [route, targetFunction] = routes[i];
251 | const [regex, groupNames] = preparedRoutes[route]
252 | ? preparedRoutes[route]
253 | : prepareRoute(route);
254 |
255 | const result = currentPath.match(regex);
256 | if (!result) {
257 | targetFunction = emptyFunc;
258 | continue;
259 | }
260 |
261 | if (groupNames.length) {
262 | targetProps = {};
263 | for (let j = 0; j < groupNames.length; j++) {
264 | targetProps[groupNames[j]] = result[j + 1];
265 | }
266 | }
267 |
268 | reducedPath = currentPath.replace(result[0], '');
269 | anyMatched = true;
270 | break;
271 | }
272 |
273 | if (!stack[routerId]) {
274 | return;
275 | }
276 |
277 | if (!anyMatched) {
278 | route = null;
279 | targetFunction = null;
280 | targetProps = null;
281 | reducedPath = null;
282 | }
283 |
284 | const funcsDiffer = resultFunc !== targetFunction;
285 | const pathDiffer = reducedPath !== previousReducedPath;
286 | let propsDiffer = true;
287 |
288 | if (!funcsDiffer) {
289 | if (!resultProps && !targetProps) {
290 | propsDiffer = false;
291 | } else {
292 | propsDiffer = !(resultProps && targetProps && objectsEqual(resultProps, targetProps) === true);
293 | }
294 |
295 | if (!propsDiffer) {
296 | if (!pathDiffer) {
297 | return;
298 | }
299 | }
300 | }
301 |
302 | const result = funcsDiffer || propsDiffer
303 | ? targetFunction
304 | ? targetFunction(targetProps)
305 | : null
306 | : stackObj.result;
307 |
308 | Object.assign(stack[routerId], {
309 | result,
310 | reducedPath,
311 | matchedRoute: route,
312 | passContext: route ? route.substr(-1) === '*' : false
313 | });
314 |
315 | if (!directCall && (funcsDiffer || propsDiffer || route === null)) {
316 | setUpdate(Date.now());
317 | }
318 | };
319 |
320 | /**
321 | * If a route returns a function, instead of a react element, we need to wrap this function
322 | * to eventually wrap a context object around its result.
323 | * @param RouteContext
324 | * @param originalResult
325 | * @returns {function(): *}
326 | */
327 | const wrapperFunction = (RouteContext, originalResult) => function (){
328 | return (
329 | {originalResult.apply(originalResult, arguments)}
330 | );
331 | };
332 |
333 | /**
334 | * Pass an object to this function where the keys are routes and the values
335 | * are functions to be executed when a route matches. Whatever your function returns
336 | * will be returned from the hook as well into your react component. Ideally you would
337 | * return components to be rendered when certain routes match, but you are not limited
338 | * to that.
339 | * @param {object} routeObj {"/someRoute": () => }
340 | */
341 | export const useRoutes = (routeObj) => {
342 | // Each router gets an internal id to look them up again.
343 | const [routerId] = React.useState(componentId);
344 | const setUpdate = React.useState(0)[1];
345 | // Needed to create nested routers which use only a subset of the URL.
346 | const parentRouterId = React.useContext(ParentContext);
347 |
348 | // If we just took the last ID, increase it for the next hook.
349 | if (routerId === componentId) {
350 | componentId += 1;
351 | }
352 |
353 | // Removes the router from the stack after component unmount - it won't be processed anymore.
354 | React.useEffect(() => () => delete stack[routerId], [routerId]);
355 |
356 | let stackObj = stack[routerId];
357 |
358 | if (stackObj && stackObj.originalRouteObj !== routeObj) {
359 | stackObj = null;
360 | }
361 |
362 | if (!stackObj) {
363 | stackObj = {
364 | routerId,
365 | originalRouteObj: routeObj,
366 | routes: Object.entries(routeObj),
367 | setUpdate,
368 | parentRouterId,
369 | matchedRoute: null,
370 | reducedPath: null,
371 | passContext: false,
372 | result: null
373 | };
374 |
375 | stack[routerId] = stackObj;
376 |
377 | process(stackObj, true);
378 | }
379 |
380 | React.useDebugValue(stackObj.matchedRoute);
381 |
382 | if (!stackObj.matchedRoute) {
383 | return null;
384 | }
385 |
386 | let result = stackObj.result;
387 |
388 | if (!stackObj.passContext) {
389 | return result;
390 | } else {
391 | const RouteContext = ({children}) => {children};
392 |
393 | if (typeof result === 'function') {
394 | return wrapperFunction(RouteContext, result);
395 | }
396 |
397 | return React.isValidElement(result) && result.type !== RouteContext
398 | ? {result}
399 | : result;
400 | }
401 | };
402 |
--------------------------------------------------------------------------------