├── .gitignore ├── src ├── isNode.js ├── redirect.js ├── title.js ├── index.js ├── interceptor.js ├── controlledInterceptor.js ├── Link.js ├── queryParams.js └── router.js ├── test ├── index.html ├── src │ ├── index.js │ ├── ssr.js │ ├── QueryParamTest.js │ └── App.js └── basepath │ └── index.html ├── babel.config.js ├── unittest ├── __snapshots__ │ └── Link.test.js.snap └── Link.test.js ├── src-docs └── pages │ └── en │ ├── 01_overview.md │ ├── 05_serverside-rendering.md │ ├── README.md │ ├── 04_other-features.md │ ├── 02_routing.md │ └── 03_navigation.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /src/isNode.js: -------------------------------------------------------------------------------- 1 | let wIsNode = true; 2 | try { 3 | wIsNode = window === undefined; 4 | } catch (e) { 5 | } 6 | 7 | export default wIsNode; 8 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HookRouter Test 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {setBasepath} from "../../dist"; 4 | 5 | if(location.pathname.match(/^\/basepath/)){ 6 | setBasepath('/basepath'); 7 | } 8 | 9 | import App from './App'; 10 | 11 | ReactDOM.render(, document.getElementById('root')); 12 | -------------------------------------------------------------------------------- /test/basepath/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Basepath test 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | const presets = []; 3 | 4 | // Resolve ES2015+ only before publishing to NPM 5 | if (process.env['NODE_ENV'] === 'production') { 6 | presets.push('@babel/preset-env'); 7 | } 8 | 9 | presets.push('@babel/preset-react'); 10 | 11 | const plugins = []; 12 | 13 | api.cache(false); 14 | 15 | return { 16 | presets, 17 | plugins 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/redirect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {navigate, ParentContext, getWorkingPath} from './router'; 3 | 4 | const useRedirect = (fromURL, toURL, queryParams = null, replace = true) => { 5 | const parentRouterId = React.useContext(ParentContext); 6 | const currentPath = getWorkingPath(parentRouterId); 7 | 8 | if (currentPath === fromURL) { 9 | navigate(parentRouterId ? `.${toURL}` : toURL, replace, queryParams); 10 | } 11 | }; 12 | 13 | export default useRedirect; 14 | -------------------------------------------------------------------------------- /test/src/ssr.js: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const {renderToString} = require('react-dom/server'); 3 | const hookrouter = require('../../dist'); 4 | 5 | const path = '/product'; 6 | 7 | hookrouter.setPath(path); 8 | hookrouter.setQueryParams({sort: 'desc'}); 9 | 10 | import App from './App'; 11 | 12 | const result = renderToString(); 13 | 14 | console.log(`Rendering with path "${path}"`); 15 | console.log(`Ended up on "${hookrouter.getPath()}"`); 16 | console.log(result); 17 | 18 | -------------------------------------------------------------------------------- /src/title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import isNode from './isNode'; 3 | 4 | let currentTitle = ''; 5 | 6 | /** 7 | * This hook will set the window title, when a component gets mounted. 8 | * When the component gets unmounted, the previously used title will be restored. 9 | * @param {string} inString 10 | */ 11 | export const useTitle = (inString) => { 12 | currentTitle = inString; 13 | 14 | if(isNode){ 15 | return; 16 | } 17 | 18 | React.useEffect(() => { 19 | const previousTitle = document.title; 20 | document.title = inString; 21 | return () => { 22 | document.title = previousTitle; 23 | }; 24 | }); 25 | }; 26 | 27 | /** 28 | * Returns the current window title to be used in a SSR context 29 | * @returns {string} 30 | */ 31 | export const getTitle = () => currentTitle; 32 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {A, setLinkProps} from './Link'; 2 | import useRedirect from './redirect'; 3 | import {useQueryParams, setQueryParams, getQueryParams} from "./queryParams"; 4 | import {useInterceptor} from './interceptor'; 5 | import {useControlledInterceptor} from './controlledInterceptor'; 6 | import {useTitle, getTitle} from './title'; 7 | import { 8 | navigate, 9 | useRoutes, 10 | setPath, 11 | getPath, 12 | getWorkingPath, 13 | setBasepath, 14 | getBasepath, 15 | usePath, 16 | } from './router'; 17 | 18 | export { 19 | A, 20 | setLinkProps, 21 | useRedirect, 22 | useTitle, 23 | getTitle, 24 | useQueryParams, 25 | useInterceptor, 26 | useControlledInterceptor, 27 | navigate, 28 | useRoutes, 29 | setPath, 30 | getPath, 31 | getWorkingPath, 32 | setQueryParams, 33 | getQueryParams, 34 | setBasepath, 35 | getBasepath, 36 | usePath 37 | }; 38 | -------------------------------------------------------------------------------- /unittest/__snapshots__/Link.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Link.js A renders correctly with href 1`] = ` 4 | 8 | hookrouter 9 | 10 | `; 11 | 12 | exports[`Link.js A renders correctly with href and onClick 1`] = ` 13 | 17 | hookrouter 18 | 19 | `; 20 | 21 | exports[`Link.js A renders correctly with href, onClick, and target 1`] = ` 22 | 27 | hookrouter 28 | 29 | `; 30 | 31 | exports[`Link.js A renders correctly with href, onClick, and target plus basepath 1`] = ` 32 | 37 | hookrouter 38 | 39 | `; 40 | -------------------------------------------------------------------------------- /test/src/QueryParamTest.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQueryParams } from '../../dist'; 3 | 4 | const getRandomString = () => { 5 | const r = Math.random() * 1235172132118424 + 12351241; 6 | return r.toString(32); 7 | }; 8 | 9 | const QPTest = () => { 10 | const [params, setParams] = useQueryParams(); 11 | 12 | const setSome = () => setParams({ some: getRandomString() }); 13 | 14 | const setOthers = () => setParams({ others: getRandomString() }); 15 | 16 | const unsetSome = () => setParams({ some: undefined }); 17 | 18 | const unsetOthers = () => setParams({ others: undefined }); 19 | 20 | return ( 21 |
22 |
{"Current Params: " + JSON.stringify(params, null, 2)}
23 | 24 | 25 | 26 |
27 | 28 | 29 |
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 |
60 | setSearchBuffer(e.currentTarget.value)} /> 61 | 62 |
63 | ); 64 | } 65 | ``` 66 | 67 | And somewhere else: 68 | 69 | ```jsx 70 | const SearchHeader = () => { 71 | const [queryParams] = useQueryParams(); 72 | 73 | const { 74 | // Use object destructuring and a default value 75 | // if the param is not yet present in the URL. 76 | q = '' 77 | } = queryParams; 78 | 79 | return q 80 | ? `You searched for "${q}"` 81 | : 'Please enter a search text'; 82 | } 83 | ``` 84 | 85 | If `` updates the query string, `` gets re-rendered. 86 | 87 | ## Using the URI path 88 | 89 | In case you need to make use of the current URI path, you can use the `usePath()` hook: 90 | 91 | ```jsx 92 | import {usePath} from 'hookrouter'; 93 | 94 | const PathLabel = () => { 95 | const path = usePath(); 96 | return Your current location: {path}; 97 | } 98 | ``` 99 | 100 | The hook will automatically render the component again, if the path changes. If you don't need that, 101 | you can use the hook in passive mode: `usePath(false)` and it just returns the current path when the component 102 | renders and does not trigger renders upon path change. 103 | -------------------------------------------------------------------------------- /test/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | useRoutes, 4 | useTitle, 5 | usePath, 6 | useRedirect, 7 | useQueryParams, 8 | useInterceptor, 9 | useControlledInterceptor, 10 | A, 11 | setLinkProps 12 | } from '../../dist'; 13 | import QPTest from './QueryParamTest'; 14 | 15 | const products = { 16 | "1": "Rainbow Fish", 17 | "2": "Glass of Water", 18 | "3": "Plush Snake", 19 | }; 20 | 21 | const Home = () => { 22 | useTitle('Home'); 23 | return ( 24 | 25 |

Welcome

26 |

27 | This is a testing app for the hookrouter module. 28 |

29 |
30 | ); 31 | }; 32 | const About = () => { 33 | useTitle('About'); 34 | return ( 35 | 36 |

About this App

37 |

38 | Its really just for testing purposes. 39 |

40 |
41 | ); 42 | }; 43 | const LockIn = () => { 44 | useTitle('Lock-In'); 45 | const stopInterception = useInterceptor((currentPath, nextPath) => { 46 | console.log(currentPath, nextPath); 47 | return currentPath; 48 | }); 49 | 50 | return ( 51 | 52 |

Oh dear.

53 |

54 | You are not allowed to leave this page. 55 |

56 |

57 | 58 |

59 |
60 | ); 61 | }; 62 | 63 | const TimeTrap = () => { 64 | const [nextPath, confirmNavigation] = useControlledInterceptor(); 65 | 66 | React.useEffect(() => { 67 | if (!nextPath) { 68 | return; 69 | } 70 | console.log(nextPath); 71 | setTimeout(confirmNavigation, 1000); 72 | }, [nextPath]); 73 | 74 | return ( 75 | 76 |

Time trap

77 |

78 | Navigate somewhere else. I will wait a second, before I let you go. 79 |

80 |
81 | ); 82 | }; 83 | 84 | const Products = () => { 85 | useTitle('Products'); 86 | const [queryParams, setQueryParams] = useQueryParams(); 87 | 88 | const { 89 | sort = 'asc' 90 | } = queryParams; 91 | 92 | return ( 93 | 94 |

Product overview

95 | 98 |
    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 |
  • {title}
  • 115 | ))} 116 |
117 |
118 | ); 119 | }; 120 | 121 | const Product = ({id}) => { 122 | useTitle(`Product "${products[id]}"`); 123 | return ( 124 | 125 |

Buy "{products[id]}" today

126 |

127 | You won't regret it. 128 |

129 |
    130 |
  • Overview
  • 131 |
  • Ratings
  • 132 |
  • Buy Now
  • 133 |
134 |
135 | ); 136 | }; 137 | 138 | const routes = { 139 | '/welcome': () => , 140 | '/about': () => , 141 | '/prison': () => , 142 | '/timeTrap': () => , 143 | '/product': () => , 144 | '/product/:id*': ({id}) => , 145 | '/qpTest': () => 146 | }; 147 | 148 | const PathLabel = () => { 149 | const path = usePath(); 150 | return

Current path: {path}

; 151 | }; 152 | 153 | const SmartNotFound = () => { 154 | const path = usePath(); 155 | return ( 156 | 157 |

404 - Not Found

158 |

Current path: {path}

159 |
160 | ); 161 | }; 162 | 163 | const RouteContainer = () => { 164 | // We simulate passing a fresh routes object on every call, here. 165 | const routeResult = useRoutes(Object.assign({}, routes)); 166 | return routeResult || ; 167 | }; 168 | 169 | const App = () => { 170 | useRedirect('/', '/welcome'); 171 | 172 | return ( 173 | 174 |

HookRouter test app

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 |
82 | Show about area 83 | {routeResult || } 84 |
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 |
107 | About people 108 | About our company 109 | {routeResult} 110 |
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 |
91 | Show my product 92 | {routeResult || } 93 |
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 |
146 | ... 147 |
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 |
193 | ... 194 |
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 | --------------------------------------------------------------------------------