├── .github └── workflows │ ├── node.js.yml │ └── size.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── index.d.ts ├── mangle.json ├── match ├── index.d.ts ├── package.json └── src │ ├── cjs.js │ └── index.js ├── package-lock.json ├── package.json ├── src ├── cjs.js ├── index.js └── util.js └── test ├── .babelrc ├── custom-history.test.js ├── dist.test.js ├── dom.test.js ├── index.test.js ├── match.tsx ├── router.tsx ├── tsconfig.json ├── util.test.js └── utils └── assert-clone-of.js /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '**' 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build_test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: '14.x' 19 | - uses: actions/cache@v2 20 | with: 21 | path: ~/.npm 22 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.os }}-node- 25 | - run: npm ci 26 | - run: npm run test 27 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: Compressed Size 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: preactjs/compressed-size-action@v2 13 | with: 14 | pattern: "./{dist,match}/*.{js,mjs}" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /coverage 3 | node_modules 4 | /npm-debug.log 5 | .DS_Store 6 | /match/*.js 7 | /match/*.mjs 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jason Miller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # preact-router 2 | 3 | [![NPM](https://img.shields.io/npm/v/preact-router.svg)](https://www.npmjs.com/package/preact-router) 4 | [![Build status](https://github.com/preactjs/preact-router/actions/workflows/node.js.yml/badge.svg)](https://github.com/preactjs/preact-router/actions/workflows/node.js.yml) 5 | 6 | > [!WARNING] 7 | > `preact-router` unfortunately no longer sees active development! It's completely stable and so you can rely upon it for all existing apps, but for newer ones, we'd recommend using [`preact-iso`](https://github.com/preactjs/preact-iso) for your routing needs instead. It offers a very similar API while integrating a bit better Suspense and lazy loading, with potentially more useful hooks. Thanks to all the contributors and users over the years! 8 | 9 | Connect your [Preact](https://github.com/preactjs/preact) components up to that address bar. 10 | 11 | `preact-router` provides a `` component that conditionally renders its children when the URL matches their `path`. It also automatically wires up `` elements to the router. 12 | 13 | > 💁 **Note:** This is not a preact-compatible version of React Router. `preact-router` is a simple URL wiring and does no orchestration for you. 14 | > 15 | > If you're looking for more complex solutions like nested routes and view composition, [react-router](https://github.com/ReactTraining/react-router) works great with preact as long as you alias in [preact/compat](https://preactjs.com/guide/v10/getting-started#aliasing-react-to-preact). 16 | 17 | #### [See a Real-world Example :arrow_right:](https://jsfiddle.net/developit/qc73v9va/) 18 | 19 | --- 20 | 21 | ### Usage Example 22 | 23 | ```js 24 | import Router from 'preact-router'; 25 | import { h, render } from 'preact'; 26 | /** @jsx h */ 27 | 28 | const Main = () => ( 29 | 30 | 31 | 32 | // Advanced is an optional query 33 | 34 | 35 | ); 36 | 37 | render(
, document.body); 38 | ``` 39 | 40 | If there is an error rendering the destination route, a 404 will be displayed. 41 | 42 | #### Caveats 43 | 44 | Because the `path` and `default` props are used by the router, it's best to avoid using those props for your component(s) as they will conflict. 45 | 46 | ### Handling URLS 47 | 48 | :information_desk_person: Pages are just regular components that get mounted when you navigate to a certain URL. 49 | Any URL parameters get passed to the component as `props`. 50 | 51 | Defining what component(s) to load for a given URL is easy and declarative. 52 | Querystring and `:parameter` values are passed to the matched component as props. 53 | Parameters can be made optional by adding a `?`, or turned into a wildcard match by adding `*` (zero or more characters) or `+` (one or more characters): 54 | 55 | ```js 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ``` 66 | 67 | ### Lazy Loading 68 | 69 | Lazy loading (code splitting) with `preact-router` can be implemented easily using the [AsyncRoute](https://www.npmjs.com/package/preact-async-route) module: 70 | 71 | ```js 72 | import AsyncRoute from 'preact-async-route'; 73 | 74 | 75 | import('./friends').then(module => module.default)} 78 | /> 79 | import('./friend').then(module => module.default)} 82 | loading={() =>
loading...
} 83 | /> 84 |
; 85 | ``` 86 | 87 | ### Active Matching & Links 88 | 89 | `preact-router` includes an add-on module called `match` that lets you wire your components up to Router changes. 90 | 91 | Here's a demo of ``, which invokes the function you pass it (as its only child) in response to any routing: 92 | 93 | ```js 94 | import Router from 'preact-router'; 95 | import Match from 'preact-router/match'; 96 | 97 | render( 98 |
99 | {({ matches, path, url }) =>
{url}
}
100 | 101 |
demo fallback route
102 |
103 |
104 | ); 105 | 106 | // another example: render only if at a given URL: 107 | 108 | render( 109 |
110 | {({ matches }) => matches &&

You are Home!

}
111 | 112 |
113 | ); 114 | ``` 115 | 116 | `` is just a normal link, but it automatically adds and removes an "active" classname to itself based on whether it matches the current URL. 117 | 118 | ```js 119 | import { Router } from 'preact-router'; 120 | import { Link } from 'preact-router/match'; 121 | 122 | render( 123 |
124 | 135 | 136 |
this is a demo route that always matches
137 |
138 |
139 | ); 140 | ``` 141 | 142 | ### Default Link Behavior 143 | 144 | Sometimes it's necessary to bypass preact-router's link handling and let the browser perform routing on its own. 145 | 146 | This can be accomplished by adding a `data-native` boolean attribute to any link: 147 | 148 | ```html 149 |
Foo 150 | ``` 151 | 152 | ### Detecting Route Changes 153 | 154 | The `Router` notifies you when a change event occurs for a route with the `onChange` callback: 155 | 156 | ```js 157 | import { render, Component } from 'preact'; 158 | import { Router, route } from 'preact-router'; 159 | 160 | class App extends Component { 161 | // some method that returns a promise 162 | isAuthenticated() {} 163 | 164 | handleRoute = async e => { 165 | switch (e.url) { 166 | case '/profile': 167 | const isAuthed = await this.isAuthenticated(); 168 | if (!isAuthed) route('/', true); 169 | break; 170 | } 171 | }; 172 | 173 | render() { 174 | return ( 175 | 176 | 177 | 178 | 179 | ); 180 | } 181 | } 182 | ``` 183 | 184 | ### Redirects 185 | 186 | Can easily be implemented with a custom `Redirect` component; 187 | 188 | ```js 189 | import { Component } from 'preact'; 190 | import { route } from 'preact-router'; 191 | 192 | export default class Redirect extends Component { 193 | componentWillMount() { 194 | route(this.props.to, true); 195 | } 196 | 197 | render() { 198 | return null; 199 | } 200 | } 201 | ``` 202 | 203 | Now to create a redirect within your application, you can add this `Redirect` component to your router; 204 | 205 | ```js 206 | 207 | 208 | 209 | 210 | ``` 211 | 212 | ### Custom History 213 | 214 | It's possible to use alternative history bindings, like `/#!/hash-history`: 215 | 216 | ```js 217 | import { h } from 'preact'; 218 | import Router from 'preact-router'; 219 | import { createHashHistory } from 'history'; 220 | 221 | const Main = () => ( 222 | 223 | 224 | 225 | 226 | 227 | ); 228 | 229 | render(
, document.body); 230 | ``` 231 | 232 | ### Programmatically Triggering Route 233 | 234 | It's possible to programmatically trigger a route to a page (like `window.location = '/page-2'`) 235 | 236 | ```js 237 | import { route } from 'preact-router'; 238 | 239 | route('/page-2'); // appends a history entry 240 | 241 | route('/page-3', true); // replaces the current history entry 242 | ``` 243 | 244 | ### Nested Routers 245 | 246 | The `` is a self-contained component that renders based on the page URL. When nested a Router inside of another Router, the inner Router does not share or observe the outer's URL or matches. Instead, inner routes must include the full path to be matched against the page's URL: 247 | 248 | ```js 249 | import { h, render } from 'preact'; 250 | import Router from 'preact-router'; 251 | 252 | function Profile(props) { 253 | // `props.rest` is the rest of the URL after "/profile/" 254 | return ( 255 |
256 |

Profile

257 | 258 | 259 | 260 | 261 |
262 | ); 263 | } 264 | const MyProfile = () =>

My Profile

; 265 | const UserProfile = props =>

{props.user}

; 266 | 267 | function App() { 268 | return ( 269 |
270 | 271 | 272 | 273 | 274 | 279 |
280 | ); 281 | } 282 | 283 | render(, document.body); 284 | ``` 285 | 286 | ### `Route` Component 287 | 288 | Alternatively to adding the router props (`path`, `default`) directly to your component, you may want to use the `Route` component we provide instead. This tends to appease TypeScript, while still passing down the routing props into your component for use. 289 | 290 | ```js 291 | import { Router, Route } from 'preact-router'; 292 | 293 | function App() { 294 | let users = getUsers(); 295 | 296 | return ( 297 | 298 | 299 | {/* Route will accept any props of `component` type */} 300 | 301 | 302 | ); 303 | } 304 | ``` 305 | 306 | ### License 307 | 308 | [MIT](./LICENSE) 309 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as preact from 'preact'; 2 | 3 | export function route(url: string, replace?: boolean): boolean; 4 | export function route(options: { url: string; replace?: boolean }): boolean; 5 | 6 | export function exec(url: string, route: string, opts: { default?: boolean }): false | Record; 7 | 8 | export function getCurrentUrl(): string; 9 | 10 | export interface Location { 11 | pathname: string; 12 | search: string; 13 | } 14 | 15 | export interface CustomHistory { 16 | listen(callback: (location: Location) => void): () => void; 17 | location: Location; 18 | push(path: string): void; 19 | replace(path: string): void; 20 | } 21 | 22 | export interface RoutableProps { 23 | path?: string; 24 | default?: boolean | preact.JSX.SignalLike; 25 | } 26 | 27 | export interface RouterOnChangeArgs< 28 | RouteParams extends Record | null = Record< 29 | string, 30 | string | undefined 31 | > | null 32 | > { 33 | router: Router; 34 | url: string; 35 | previous?: string; 36 | active: preact.VNode[]; 37 | current: preact.VNode; 38 | path: string | null; 39 | matches: RouteParams; 40 | } 41 | 42 | export interface RouterProps< 43 | RouteParams extends Record | null = Record< 44 | string, 45 | string | undefined 46 | > | null 47 | > extends RoutableProps { 48 | history?: CustomHistory; 49 | static?: boolean; 50 | url?: string; 51 | onChange?: (args: RouterOnChangeArgs) => void; 52 | } 53 | 54 | export class Router extends preact.Component { 55 | canRoute(url: string): boolean; 56 | routeTo(url: string): boolean; 57 | render(props: RouterProps, {}): preact.VNode; 58 | } 59 | 60 | type AnyComponent = 61 | | preact.FunctionalComponent 62 | | preact.ComponentConstructor; 63 | 64 | export interface RouteProps extends RoutableProps { 65 | component: AnyComponent; 66 | } 67 | 68 | export function Route( 69 | props: RouteProps & Partial 70 | ): preact.VNode; 71 | 72 | export function Link( 73 | props: preact.JSX.HTMLAttributes 74 | ): preact.VNode; 75 | 76 | export function useRouter< 77 | RouteParams extends Record | null = Record< 78 | string, 79 | string | undefined 80 | > | null 81 | >(): [ 82 | RouterOnChangeArgs, 83 | ( 84 | urlOrOptions: string | { url: string; replace?: boolean }, 85 | replace?: boolean 86 | ) => boolean 87 | ]; 88 | 89 | declare module 'preact' { 90 | export interface Attributes extends RoutableProps {} 91 | } 92 | 93 | export default Router; 94 | -------------------------------------------------------------------------------- /mangle.json: -------------------------------------------------------------------------------- 1 | { 2 | "minify": { 3 | "mangle": { 4 | "properties": { 5 | "regex": "^_[^_]" 6 | } 7 | } 8 | }, 9 | "props": { 10 | "cname": 6, 11 | "props": { 12 | "$_getMatchingChild": "g", 13 | "$_updating": "p", 14 | "$_unlisten": "u", 15 | "$_contextValue": "c" 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /match/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as preact from 'preact'; 2 | 3 | import { Link as StaticLink, RoutableProps } from '..'; 4 | 5 | export class Match extends preact.Component { 6 | render(): preact.VNode; 7 | } 8 | 9 | export interface LinkProps extends preact.JSX.HTMLAttributes { 10 | activeClassName?: string; 11 | children?: preact.ComponentChildren; 12 | } 13 | 14 | export function Link(props: LinkProps): preact.VNode; 15 | 16 | export default Match; 17 | -------------------------------------------------------------------------------- /match/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "index", 3 | "main": "./index.js", 4 | "module": "./index.module.js", 5 | "types": "./index.d.ts", 6 | "scripts": { 7 | "build": "microbundle src/index.js -f es -o . --no-sourcemap --no-generateTypes && microbundle src/cjs.js -f cjs -o . --no-sourcemap --no-generateTypes" 8 | }, 9 | "peerDependencies": { 10 | "preact": "*", 11 | "preact-router": "*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /match/src/cjs.js: -------------------------------------------------------------------------------- 1 | import { Match, Link } from './index'; 2 | 3 | Match.Link = Link; 4 | export default Match; 5 | -------------------------------------------------------------------------------- /match/src/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Link as StaticLink, exec, useRouter } from 'preact-router'; 3 | 4 | export function Match(props) { 5 | const router = useRouter()[0]; 6 | return props.children({ 7 | url: router.url, 8 | path: router.path, 9 | matches: exec(router.path || router.url, props.path, {}) !== false 10 | }); 11 | } 12 | 13 | export function Link({ 14 | className, 15 | activeClass, 16 | activeClassName, 17 | path, 18 | ...props 19 | }) { 20 | const router = useRouter()[0]; 21 | const matches = 22 | (path && router.path && exec(router.path, path, {})) || 23 | exec(router.url, props.href, {}); 24 | 25 | let inactive = props.class || className || ''; 26 | let active = (matches && (activeClass || activeClassName)) || ''; 27 | props.class = inactive + (inactive && active && ' ') + active; 28 | 29 | return ; 30 | } 31 | 32 | export default Match; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "preact-router", 3 | "amdName": "preactRouter", 4 | "version": "4.1.2", 5 | "description": "Connect your components up to that address bar.", 6 | "main": "dist/preact-router.js", 7 | "module": "dist/preact-router.module.js", 8 | "jsnext:main": "dist/preact-router.module.js", 9 | "umd:main": "dist/preact-router.umd.js", 10 | "unpkg": "dist/preact-router.umd.js", 11 | "exports": { 12 | ".": { 13 | "types": "./index.d.ts", 14 | "module": "./dist/preact-router.mjs", 15 | "import": "./dist/preact-router.mjs", 16 | "require": "./dist/preact-router.js" 17 | }, 18 | "./package.json": "./package.json", 19 | "./match": { 20 | "types": "./match/index.d.ts", 21 | "module": "./match/index.mjs", 22 | "import": "./match/index.mjs", 23 | "require": "./match/index.js" 24 | }, 25 | "./match/package.json": "./match/package.json" 26 | }, 27 | "scripts": { 28 | "build": "microbundle -f es --no-generateTypes && microbundle src/cjs.js -f cjs,umd --no-generateTypes && (cd match && npm run build)", 29 | "postbuild": "cp dist/preact-router.module.js dist/preact-router.mjs && cp match/index.module.js match/index.mjs", 30 | "test": "npm-run-all lint build test:unit test:ts", 31 | "lint": "eslint src test", 32 | "fix": "npm run lint -- --fix", 33 | "test:unit": "karmatic", 34 | "test:ts": "tsc -p ./test", 35 | "prepublishOnly": "npm-run-all build test", 36 | "release": "npm run build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish", 37 | "format": "prettier --write ." 38 | }, 39 | "files": [ 40 | "dist", 41 | "match", 42 | "index.d.ts" 43 | ], 44 | "typings": "./index.d.ts", 45 | "keywords": [ 46 | "preact", 47 | "router" 48 | ], 49 | "author": "Jason Miller ", 50 | "license": "MIT", 51 | "repository": "preactjs/preact-router", 52 | "homepage": "https://github.com/preactjs/preact-router", 53 | "eslintConfig": { 54 | "extends": "preact", 55 | "rules": { 56 | "jest/no-jasmine-globals": 0 57 | }, 58 | "globals": { 59 | "spyOn": "readonly" 60 | } 61 | }, 62 | "husky": { 63 | "hooks": { 64 | "pre-commit": "lint-staged" 65 | } 66 | }, 67 | "lint-staged": { 68 | "**/*.{js,ts,jsx,tsx}": [ 69 | "prettier --write" 70 | ], 71 | "*.json": [ 72 | "prettier --write" 73 | ] 74 | }, 75 | "prettier": { 76 | "singleQuote": true, 77 | "useTabs": true, 78 | "trailingComma": "none", 79 | "arrowParens": "avoid" 80 | }, 81 | "peerDependencies": { 82 | "preact": ">=10" 83 | }, 84 | "devDependencies": { 85 | "@babel/plugin-transform-react-jsx": "^7.9.1", 86 | "@types/jasmine": "^3.10.3", 87 | "chai": "^4.3.7", 88 | "eslint": "^6.8.0", 89 | "eslint-config-preact": "^1.1.1", 90 | "history": "^5.2.0", 91 | "husky": "^7.0.2", 92 | "karmatic": "^2.1.0", 93 | "lint-staged": "^11.1.2", 94 | "microbundle": "^0.14.2", 95 | "mocha": "^5.2.0", 96 | "npm-run-all": "^3.0.0", 97 | "preact": "^10.16.0", 98 | "prettier": "^2.3.2", 99 | "sinon": "^7.1.0", 100 | "sinon-chai": "^3.7.0", 101 | "typescript": "^4.9.5", 102 | "webpack": "^4.42.0" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/cjs.js: -------------------------------------------------------------------------------- 1 | import { 2 | getCurrentUrl, 3 | route, 4 | Router, 5 | Route, 6 | Link, 7 | exec, 8 | useRouter 9 | } from './index'; 10 | 11 | Router.getCurrentUrl = getCurrentUrl; 12 | Router.route = route; 13 | Router.Router = Router; 14 | Router.Route = Route; 15 | Router.Link = Link; 16 | Router.exec = exec; 17 | Router.useRouter = useRouter; 18 | 19 | export default Router; 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | h, 3 | cloneElement, 4 | Component, 5 | toChildArray, 6 | createContext 7 | } from 'preact'; 8 | import { useContext, useState, useEffect } from 'preact/hooks'; 9 | import { exec, prepareVNodeForRanking, assign, pathRankSort } from './util'; 10 | 11 | const EMPTY = {}; 12 | const ROUTERS = []; 13 | const SUBS = []; 14 | let customHistory = null; 15 | 16 | const GLOBAL_ROUTE_CONTEXT = { 17 | url: getCurrentUrl() 18 | }; 19 | 20 | const RouterContext = createContext(GLOBAL_ROUTE_CONTEXT); 21 | 22 | function useRouter() { 23 | const ctx = useContext(RouterContext); 24 | // Note: this condition can't change without a remount, so it's a safe conditional hook call 25 | if (ctx === GLOBAL_ROUTE_CONTEXT) { 26 | // eslint-disable-next-line react-hooks/rules-of-hooks 27 | const update = useState()[1]; 28 | // eslint-disable-next-line react-hooks/rules-of-hooks 29 | useEffect(() => { 30 | SUBS.push(update); 31 | return () => SUBS.splice(SUBS.indexOf(update), 1); 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | }, []); 34 | } 35 | return [ctx, route]; 36 | } 37 | 38 | function setUrl(url, type = 'push') { 39 | if (customHistory && customHistory[type]) { 40 | customHistory[type](url); 41 | } else if (typeof history !== 'undefined' && history[`${type}State`]) { 42 | history[`${type}State`](null, null, url); 43 | } 44 | } 45 | 46 | function getCurrentUrl() { 47 | let url; 48 | if (customHistory && customHistory.location) { 49 | url = customHistory.location; 50 | } else if (customHistory && customHistory.getCurrentLocation) { 51 | url = customHistory.getCurrentLocation(); 52 | } else { 53 | url = typeof location !== 'undefined' ? location : EMPTY; 54 | } 55 | return `${url.pathname || ''}${url.search || ''}`; 56 | } 57 | 58 | function route(url, replace = false) { 59 | if (typeof url !== 'string' && url.url) { 60 | replace = url.replace; 61 | url = url.url; 62 | } 63 | 64 | // only push URL into history if we can handle it 65 | if (canRoute(url)) { 66 | setUrl(url, replace ? 'replace' : 'push'); 67 | } 68 | 69 | return routeTo(url); 70 | } 71 | 72 | /** Check if the given URL can be handled by any router instances. */ 73 | function canRoute(url) { 74 | for (let i = ROUTERS.length; i--; ) { 75 | if (ROUTERS[i].canRoute(url)) return true; 76 | } 77 | return false; 78 | } 79 | 80 | /** Tell all router instances to handle the given URL. */ 81 | function routeTo(url) { 82 | let didRoute = false; 83 | for (let i = 0; i < ROUTERS.length; i++) { 84 | if (ROUTERS[i].routeTo(url)) { 85 | didRoute = true; 86 | } 87 | } 88 | return didRoute; 89 | } 90 | 91 | function routeFromLink(node) { 92 | // only valid elements 93 | if (!node || !node.getAttribute) return; 94 | 95 | let href = node.getAttribute('href'), 96 | target = node.getAttribute('target'); 97 | 98 | // ignore links with targets and non-path URLs 99 | if (!href || !href.match(/^\//g) || (target && !target.match(/^_?self$/i))) 100 | return; 101 | 102 | // attempt to route, if no match simply cede control to browser 103 | return route(href); 104 | } 105 | 106 | function prevent(e) { 107 | if (e.stopImmediatePropagation) e.stopImmediatePropagation(); 108 | if (e.stopPropagation) e.stopPropagation(); 109 | e.preventDefault(); 110 | return false; 111 | } 112 | 113 | // Handles both delegated and direct-bound link clicks 114 | function delegateLinkHandler(e) { 115 | // ignore events the browser takes care of already: 116 | if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button) return; 117 | 118 | let t = e.target; 119 | do { 120 | if (t.localName === 'a' && t.getAttribute('href')) { 121 | if (t.hasAttribute('data-native') || t.hasAttribute('native')) return; 122 | // if link is handled by the router, prevent browser defaults 123 | if (routeFromLink(t)) { 124 | return prevent(e); 125 | } 126 | } 127 | } while ((t = t.parentNode)); 128 | } 129 | 130 | let eventListenersInitialized = false; 131 | 132 | function initEventListeners() { 133 | if (eventListenersInitialized) return; 134 | eventListenersInitialized = true; 135 | 136 | if (!customHistory) { 137 | addEventListener('popstate', () => { 138 | routeTo(getCurrentUrl()); 139 | }); 140 | } 141 | addEventListener('click', delegateLinkHandler); 142 | } 143 | 144 | /** 145 | * @class 146 | * @this {import('preact').Component} 147 | */ 148 | function Router(props) { 149 | if (props.history) { 150 | customHistory = props.history; 151 | } 152 | 153 | this.state = { 154 | url: props.url || getCurrentUrl() 155 | }; 156 | } 157 | 158 | // @ts-ignore-next-line 159 | const RouterProto = (Router.prototype = new Component()); 160 | 161 | assign(RouterProto, { 162 | shouldComponentUpdate(props) { 163 | if (props.static !== true) return true; 164 | return ( 165 | props.url !== this.props.url || props.onChange !== this.props.onChange 166 | ); 167 | }, 168 | 169 | /** Check if the given URL can be matched against any children */ 170 | canRoute(url) { 171 | const children = toChildArray(this.props.children); 172 | return this._getMatchingChild(children, url) !== undefined; 173 | }, 174 | 175 | /** Re-render children with a new URL to match against. */ 176 | routeTo(url) { 177 | this.setState({ url }); 178 | 179 | const didRoute = this.canRoute(url); 180 | 181 | // trigger a manual re-route if we're not in the middle of an update: 182 | if (!this._updating) this.forceUpdate(); 183 | 184 | return didRoute; 185 | }, 186 | 187 | componentWillMount() { 188 | this._updating = true; 189 | }, 190 | 191 | componentDidMount() { 192 | initEventListeners(); 193 | ROUTERS.push(this); 194 | if (customHistory) { 195 | this._unlisten = customHistory.listen(action => { 196 | let location = action.location || action; 197 | this.routeTo(`${location.pathname || ''}${location.search || ''}`); 198 | }); 199 | } 200 | this._updating = false; 201 | }, 202 | 203 | componentWillUnmount() { 204 | if (typeof this._unlisten === 'function') this._unlisten(); 205 | ROUTERS.splice(ROUTERS.indexOf(this), 1); 206 | }, 207 | 208 | componentWillUpdate() { 209 | this._updating = true; 210 | }, 211 | 212 | componentDidUpdate() { 213 | this._updating = false; 214 | }, 215 | 216 | _getMatchingChild(children, url) { 217 | children = children.filter(prepareVNodeForRanking).sort(pathRankSort); 218 | for (let i = 0; i < children.length; i++) { 219 | let vnode = children[i]; 220 | let matches = exec(url, vnode.props.path, vnode.props); 221 | if (matches) return [vnode, matches]; 222 | } 223 | }, 224 | 225 | render({ children, onChange }, { url }) { 226 | let ctx = this._contextValue; 227 | 228 | let active = this._getMatchingChild(toChildArray(children), url); 229 | let matches, current; 230 | if (active) { 231 | matches = active[1]; 232 | current = cloneElement( 233 | active[0], 234 | assign(assign({ url, matches }, matches), { 235 | key: undefined, 236 | ref: undefined 237 | }) 238 | ); 239 | } 240 | 241 | if (url !== (ctx && ctx.url)) { 242 | let newCtx = { 243 | url, 244 | previous: ctx && ctx.url, 245 | current, 246 | path: current ? current.props.path : null, 247 | matches 248 | }; 249 | 250 | // only copy simple properties to the global context: 251 | assign(GLOBAL_ROUTE_CONTEXT, (ctx = this._contextValue = newCtx)); 252 | 253 | // these are only available within the subtree of a Router: 254 | ctx.router = this; 255 | ctx.active = current ? [current] : []; 256 | 257 | // notify useRouter subscribers outside this subtree: 258 | for (let i = SUBS.length; i--; ) SUBS[i]({}); 259 | 260 | if (typeof onChange === 'function') { 261 | onChange(ctx); 262 | } 263 | } 264 | 265 | return ( 266 | {current} 267 | ); 268 | } 269 | }); 270 | 271 | const Link = props => h('a', assign({ onClick: delegateLinkHandler }, props)); 272 | 273 | const Route = props => h(props.component, props); 274 | 275 | export { getCurrentUrl, route, Router, Route, Link, exec, useRouter }; 276 | export default Router; 277 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const EMPTY = {}; 2 | 3 | export function assign(obj, props) { 4 | // eslint-disable-next-line guard-for-in 5 | for (let i in props) { 6 | obj[i] = props[i]; 7 | } 8 | return obj; 9 | } 10 | 11 | export function exec(url, route, opts) { 12 | let reg = /(?:\?([^#]*))?(#.*)?$/, 13 | c = url.match(reg), 14 | matches = {}, 15 | ret; 16 | if (c && c[1]) { 17 | let p = c[1].split('&'); 18 | for (let i = 0; i < p.length; i++) { 19 | let r = p[i].split('='); 20 | matches[decodeURIComponent(r[0])] = decodeURIComponent( 21 | r.slice(1).join('=') 22 | ); 23 | } 24 | } 25 | url = segmentize(url.replace(reg, '')); 26 | route = segmentize(route || ''); 27 | let max = Math.max(url.length, route.length); 28 | for (let i = 0; i < max; i++) { 29 | if (route[i] && route[i].charAt(0) === ':') { 30 | let param = route[i].replace(/(^:|[+*?]+$)/g, ''), 31 | flags = (route[i].match(/[+*?]+$/) || EMPTY)[0] || '', 32 | plus = ~flags.indexOf('+'), 33 | star = ~flags.indexOf('*'), 34 | val = url[i] || ''; 35 | if (!val && !star && (flags.indexOf('?') < 0 || plus)) { 36 | ret = false; 37 | break; 38 | } 39 | matches[param] = decodeURIComponent(val); 40 | if (plus || star) { 41 | matches[param] = url.slice(i).map(decodeURIComponent).join('/'); 42 | break; 43 | } 44 | } else if (route[i] !== url[i]) { 45 | ret = false; 46 | break; 47 | } 48 | } 49 | if (opts.default !== true && ret === false) return false; 50 | return matches; 51 | } 52 | 53 | export function pathRankSort(a, b) { 54 | return a.rank < b.rank ? 1 : a.rank > b.rank ? -1 : a.index - b.index; 55 | } 56 | 57 | // filter out VNodes without attributes (which are unrankeable), and add `index`/`rank` properties to be used in sorting. 58 | export function prepareVNodeForRanking(vnode, index) { 59 | vnode.index = index; 60 | vnode.rank = rankChild(vnode); 61 | return vnode.props; 62 | } 63 | 64 | export function segmentize(url) { 65 | return url.replace(/(^\/+|\/+$)/g, '').split('/'); 66 | } 67 | 68 | export function rankSegment(segment) { 69 | return segment.charAt(0) == ':' 70 | ? 1 + '*+?'.indexOf(segment.charAt(segment.length - 1)) || 4 71 | : 5; 72 | } 73 | 74 | export function rank(path) { 75 | return segmentize(path).map(rankSegment).join(''); 76 | } 77 | 78 | function rankChild(vnode) { 79 | return vnode.props.default ? 0 : rank(vnode.props.path); 80 | } 81 | -------------------------------------------------------------------------------- /test/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@babel/plugin-transform-react-jsx", 5 | { 6 | "pragma": "h" 7 | } 8 | ] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /test/custom-history.test.js: -------------------------------------------------------------------------------- 1 | import { Router, route } from '../src'; 2 | import { h, render } from 'preact'; 3 | import { createHashHistory } from 'history'; 4 | 5 | const sleep = ms => new Promise(r => setTimeout(r, ms)); 6 | 7 | describe('Custom History', () => { 8 | describe('createHashHistory', () => { 9 | let scratch; 10 | 11 | beforeEach(() => { 12 | scratch = document.createElement('div'); 13 | document.body.appendChild(scratch); 14 | }); 15 | 16 | afterEach(() => { 17 | render(null, scratch); 18 | document.body.removeChild(scratch); 19 | }); 20 | 21 | it('should route from initial URL', async () => { 22 | const Home = jasmine 23 | .createSpy('Home', () =>
Home
) 24 | .and.callThrough(); 25 | const About = jasmine 26 | .createSpy('About', () =>
About
) 27 | .and.callThrough(); 28 | const Search = jasmine 29 | .createSpy('Search', () =>
Search
) 30 | .and.callThrough(); 31 | 32 | const Main = () => ( 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | location.hash = ''; 41 | render(
, scratch); 42 | await sleep(1); 43 | expect(Home).toHaveBeenCalled(); 44 | expect(Home.calls.first().args[0]).toEqual( 45 | jasmine.objectContaining({ path: '/' }) 46 | ); 47 | Home.calls.reset(); 48 | 49 | location.hash = '/about'; 50 | await sleep(1); 51 | expect(Home).not.toHaveBeenCalled(); 52 | expect(About).toHaveBeenCalled(); 53 | expect(About.calls.first().args[0]).toEqual( 54 | jasmine.objectContaining({ path: '/about' }) 55 | ); 56 | About.calls.reset(); 57 | 58 | location.hash = '/search/foo'; 59 | await sleep(1); 60 | expect(Home).not.toHaveBeenCalled(); 61 | expect(About).not.toHaveBeenCalled(); 62 | expect(Search).toHaveBeenCalled(); 63 | expect(Search.calls.first().args[0]).toEqual( 64 | jasmine.objectContaining({ 65 | path: '/search/:query', 66 | url: '/search/foo', 67 | query: 'foo' 68 | }) 69 | ); 70 | 71 | route('/'); 72 | 73 | await sleep(1); 74 | expect(location.hash).toEqual('#/'); 75 | expect(Home).toHaveBeenCalled(); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/dist.test.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { toBeCloneOf } from './utils/assert-clone-of'; 3 | // import '../test_helpers/assert-clone-of'; 4 | 5 | const router = require('../dist/preact-router.js'); 6 | const { Router, Link, route } = router; 7 | 8 | describe('dist', () => { 9 | beforeAll(() => { 10 | jasmine.addMatchers({ toBeCloneOf }); 11 | }); 12 | 13 | it('should export Router, Link and route', () => { 14 | expect(Router).toBeInstanceOf(Function); 15 | expect(Link).toBeInstanceOf(Function); 16 | expect(route).toBeInstanceOf(Function); 17 | expect(router).toBe(Router); 18 | }); 19 | 20 | describe('Router', () => { 21 | let children = [ 22 | , 23 | , 24 | 25 | ]; 26 | 27 | it('should be instantiable', () => { 28 | let router = new Router({}); 29 | expect(router).toBeInstanceOf(Router); 30 | }); 31 | 32 | it('should filter children (manual)', () => { 33 | let router = new Router({}); 34 | 35 | expect( 36 | router.render({ children }, { url: '/foo' }).props.children 37 | ).toBeCloneOf(children[1], { url: '/foo' }); 38 | 39 | expect( 40 | router.render({ children }, { url: '/' }).props.children 41 | ).toBeCloneOf(children[0]); 42 | 43 | expect( 44 | router.render({ children }, { url: '/foo/bar' }).props.children 45 | ).toBeCloneOf(children[2]); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/dom.test.js: -------------------------------------------------------------------------------- 1 | import { Router, Link, route, Route } from 'preact-router'; 2 | import { Match, Link as ActiveLink } from '../match/src'; 3 | import { h, render } from 'preact'; 4 | import { act } from 'preact/test-utils'; 5 | 6 | const sleep = ms => new Promise(r => setTimeout(r, ms)); 7 | 8 | const Empty = () => null; 9 | 10 | function fireEvent(on, type) { 11 | let e = document.createEvent('Event'); 12 | e.initEvent(type, true, true); 13 | on.dispatchEvent(e); 14 | } 15 | 16 | describe('dom', () => { 17 | let scratch, $, mount; 18 | 19 | beforeAll(() => { 20 | scratch = document.createElement('div'); 21 | document.body.appendChild(scratch); 22 | $ = s => scratch.querySelector(s); 23 | mount = jsx => { 24 | render(jsx, scratch); 25 | return scratch.lastChild; 26 | }; 27 | }); 28 | 29 | beforeEach(() => { 30 | // manually reset the URL before every test 31 | history.replaceState(null, null, '/'); 32 | fireEvent(window, 'popstate'); 33 | }); 34 | 35 | afterEach(() => { 36 | mount(); 37 | scratch.innerHTML = ''; 38 | }); 39 | 40 | afterAll(() => { 41 | document.body.removeChild(scratch); 42 | }); 43 | 44 | describe('', () => { 45 | it('should render a normal link', () => { 46 | expect( 47 | mount( 48 | 49 | hello 50 | 51 | ).outerHTML 52 | ).toEqual( 53 | mount( 54 | 55 | hello 56 | 57 | ).outerHTML 58 | ); 59 | }); 60 | 61 | it('should route when clicked', () => { 62 | let onChange = jasmine.createSpy(); 63 | mount( 64 |
65 | foo 66 | 67 |
68 | 69 |
70 | ); 71 | onChange.calls.reset(); 72 | act(() => { 73 | $('a').click(); 74 | }); 75 | expect(onChange).toHaveBeenCalled(); 76 | expect(onChange).toHaveBeenCalledWith( 77 | jasmine.objectContaining({ url: '/foo' }) 78 | ); 79 | }); 80 | }); 81 | 82 | describe('', () => { 83 | it('should route for existing routes', () => { 84 | let onChange = jasmine.createSpy(); 85 | mount( 86 |
87 | foo 88 | 89 |
90 | 91 |
92 | ); 93 | onChange.calls.reset(); 94 | act(() => { 95 | $('a').click(); 96 | }); 97 | // fireEvent($('a'), 'click'); 98 | expect(onChange).toHaveBeenCalled(); 99 | expect(onChange).toHaveBeenCalledWith( 100 | jasmine.objectContaining({ url: '/foo' }) 101 | ); 102 | }); 103 | 104 | it('should not intercept non-preact elements', () => { 105 | let onChange = jasmine.createSpy(); 106 | mount( 107 |
108 |
foo` }} /> 109 | 110 |
111 | 112 |
113 | ); 114 | onChange.calls.reset(); 115 | act(() => { 116 | $('a').click(); 117 | }); 118 | expect(onChange).not.toHaveBeenCalled(); 119 | expect(location.href).toContain('#foo'); 120 | }); 121 | }); 122 | 123 | describe('Router', () => { 124 | it('should add and remove children', () => { 125 | class A { 126 | componentWillMount() {} 127 | componentWillUnmount() {} 128 | render() { 129 | return
; 130 | } 131 | } 132 | const componentWillMount = spyOn(A.prototype, 'componentWillMount'); 133 | const componentWillUnmount = spyOn(A.prototype, 'componentWillUnmount'); 134 | mount( 135 | 136 | 137 | 138 | ); 139 | expect(componentWillMount).not.toHaveBeenCalled(); 140 | act(() => { 141 | route('/foo'); 142 | }); 143 | expect(componentWillMount).toHaveBeenCalledTimes(1); 144 | expect(componentWillUnmount).not.toHaveBeenCalled(); 145 | act(() => { 146 | route('/bar'); 147 | }); 148 | expect(componentWillMount).toHaveBeenCalledTimes(1); 149 | expect(componentWillUnmount).toHaveBeenCalledTimes(1); 150 | }); 151 | 152 | it('should support re-routing', async () => { 153 | class A { 154 | componentWillMount() { 155 | route('/b'); 156 | } 157 | render() { 158 | return
; 159 | } 160 | } 161 | class B { 162 | componentWillMount() {} 163 | render() { 164 | return
; 165 | } 166 | } 167 | const mountA = spyOn(A.prototype, 'componentWillMount'); 168 | const mountB = spyOn(B.prototype, 'componentWillMount'); 169 | mount( 170 | 171 | 172 | 173 | 174 | ); 175 | expect(mountA).not.toHaveBeenCalled(); 176 | act(() => { 177 | route('/a'); 178 | }); 179 | expect(mountA).toHaveBeenCalledTimes(1); 180 | mountA.calls.reset(); 181 | expect(location.pathname).toEqual('/a'); 182 | act(() => { 183 | route('/b'); 184 | }); 185 | 186 | await sleep(10); 187 | 188 | expect(mountA).not.toHaveBeenCalled(); 189 | expect(mountB).toHaveBeenCalledTimes(1); 190 | expect(scratch.firstElementChild.className).toBe('b'); 191 | }); 192 | 193 | it('should not carry over the previous value of a query parameter', () => { 194 | class A { 195 | render({ bar }) { 196 | return

bar is {bar}

; 197 | } 198 | } 199 | let routerRef; 200 | mount( 201 | (routerRef = r)}> 202 |
203 | 204 | ); 205 | act(() => { 206 | route('/foo'); 207 | }); 208 | expect(routerRef.base.outerHTML).toEqual('

bar is

'); 209 | act(() => { 210 | route('/foo?bar=5'); 211 | }); 212 | expect(routerRef.base.outerHTML).toEqual('

bar is 5

'); 213 | act(() => { 214 | route('/foo'); 215 | }); 216 | expect(routerRef.base.outerHTML).toEqual('

bar is

'); 217 | }); 218 | }); 219 | 220 | describe('preact-router/match', () => { 221 | describe('', () => { 222 | it('should invoke child function with match status when routing', async () => { 223 | let spy1 = jasmine.createSpy('spy1'), 224 | spy2 = jasmine.createSpy('spy2'), 225 | spy3 = jasmine.createSpy('spy3'); 226 | 227 | const components = () => [ 228 | 229 | {spy1} 230 | , 231 | 232 | {spy2} 233 | , 234 | 235 | {spy3} 236 | 237 | ]; 238 | mount( 239 |
240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 |
248 | ); 249 | 250 | expect(spy1) 251 | .withContext('spy1 /') 252 | .toHaveBeenCalledWith( 253 | jasmine.objectContaining({ matches: false, path: '/', url: '/' }) 254 | ); 255 | expect(spy2) 256 | .withContext('spy2 /') 257 | .toHaveBeenCalledWith( 258 | jasmine.objectContaining({ matches: false, path: '/', url: '/' }) 259 | ); 260 | expect(spy3) 261 | .withContext('spy3 /') 262 | .toHaveBeenCalledWith( 263 | jasmine.objectContaining({ matches: false, path: '/', url: '/' }) 264 | ); 265 | 266 | spy1.calls.reset(); 267 | spy2.calls.reset(); 268 | spy3.calls.reset(); 269 | 270 | act(() => { 271 | route('/foo'); 272 | }); 273 | 274 | await sleep(10); 275 | 276 | expect(spy1) 277 | .withContext('spy1 /foo') 278 | .toHaveBeenCalledWith( 279 | jasmine.objectContaining({ 280 | matches: true, 281 | path: '/foo', 282 | url: '/foo' 283 | }) 284 | ); 285 | expect(spy2) 286 | .withContext('spy2 /foo') 287 | .toHaveBeenCalledWith( 288 | jasmine.objectContaining({ 289 | matches: false, 290 | path: '/foo', 291 | url: '/foo' 292 | }) 293 | ); 294 | expect(spy3) 295 | .withContext('spy3 /foo') 296 | .toHaveBeenCalledWith( 297 | jasmine.objectContaining({ 298 | matches: false, 299 | path: '/foo', 300 | url: '/foo' 301 | }) 302 | ); 303 | spy1.calls.reset(); 304 | spy2.calls.reset(); 305 | spy3.calls.reset(); 306 | 307 | act(() => { 308 | route('/foo?bar=5'); 309 | }); 310 | 311 | await sleep(10); 312 | 313 | expect(spy1) 314 | .withContext('spy1 /foo?bar=5') 315 | .toHaveBeenCalledWith( 316 | jasmine.objectContaining({ 317 | matches: true, 318 | path: '/foo', 319 | url: '/foo?bar=5' 320 | }) 321 | ); 322 | expect(spy2) 323 | .withContext('spy2 /foo?bar=5') 324 | .toHaveBeenCalledWith( 325 | jasmine.objectContaining({ 326 | matches: false, 327 | path: '/foo', 328 | url: '/foo?bar=5' 329 | }) 330 | ); 331 | expect(spy3) 332 | .withContext('spy3 /foo?bar=5') 333 | .toHaveBeenCalledWith( 334 | jasmine.objectContaining({ 335 | matches: false, 336 | path: '/foo', 337 | url: '/foo?bar=5' 338 | }) 339 | ); 340 | spy1.calls.reset(); 341 | spy2.calls.reset(); 342 | spy3.calls.reset(); 343 | 344 | act(() => { 345 | route('/bar'); 346 | }); 347 | 348 | await sleep(10); 349 | 350 | expect(spy1) 351 | .withContext('spy1 /bar') 352 | .toHaveBeenCalledWith( 353 | jasmine.objectContaining({ 354 | matches: false, 355 | path: '/bar', 356 | url: '/bar' 357 | }) 358 | ); 359 | expect(spy2) 360 | .withContext('spy2 /bar') 361 | .toHaveBeenCalledWith( 362 | jasmine.objectContaining({ 363 | matches: true, 364 | path: '/bar', 365 | url: '/bar' 366 | }) 367 | ); 368 | expect(spy3) 369 | .withContext('spy3 /bar') 370 | .toHaveBeenCalledWith( 371 | jasmine.objectContaining({ 372 | matches: false, 373 | path: '/bar', 374 | url: '/bar' 375 | }) 376 | ); 377 | spy1.calls.reset(); 378 | spy2.calls.reset(); 379 | spy3.calls.reset(); 380 | 381 | act(() => { 382 | route('/bar/123'); 383 | }); 384 | 385 | await sleep(10); 386 | 387 | expect(spy1) 388 | .withContext('spy1 /bar/123') 389 | .toHaveBeenCalledWith( 390 | jasmine.objectContaining({ 391 | matches: false, 392 | path: '/bar/:param', 393 | url: '/bar/123' 394 | }) 395 | ); 396 | expect(spy2) 397 | .withContext('spy2 /bar/123') 398 | .toHaveBeenCalledWith( 399 | jasmine.objectContaining({ 400 | matches: false, 401 | path: '/bar/:param', 402 | url: '/bar/123' 403 | }) 404 | ); 405 | expect(spy3) 406 | .withContext('spy3 /bar/123') 407 | .toHaveBeenCalledWith( 408 | jasmine.objectContaining({ 409 | matches: true, 410 | path: '/bar/:param', 411 | url: '/bar/123' 412 | }) 413 | ); 414 | }); 415 | }); 416 | 417 | describe('', () => { 418 | it('should render with active class when active', async () => { 419 | const components = () => [ 420 | 421 | foo 422 | , 423 | 429 | bar 430 | 431 | ]; 432 | 433 | mount( 434 |
435 | 436 | 437 | 438 | 439 | 440 |
441 | ); 442 | route('/foo'); 443 | 444 | await sleep(1); 445 | 446 | expect(scratch.innerHTML).toEqual( 447 | '
' 448 | ); 449 | 450 | route('/foo?bar=5'); 451 | 452 | await sleep(1); 453 | 454 | expect(scratch.innerHTML).toEqual( 455 | '' 456 | ); 457 | 458 | route('/bar'); 459 | 460 | await sleep(1); 461 | 462 | expect(scratch.innerHTML).toEqual( 463 | '' 464 | ); 465 | }); 466 | }); 467 | }); 468 | }); 469 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import { Router, Link, route, useRouter } from '../src'; 2 | import { h, render } from 'preact'; 3 | import { toBeCloneOf } from './utils/assert-clone-of'; 4 | 5 | const sleep = ms => new Promise(r => setTimeout(r, ms)); 6 | 7 | describe('preact-router', () => { 8 | beforeAll(() => { 9 | jasmine.addMatchers({ toBeCloneOf }); 10 | }); 11 | 12 | it('should export Router, Link and route', () => { 13 | expect(Router).toBeInstanceOf(Function); 14 | expect(Link).toBeInstanceOf(Function); 15 | expect(route).toBeInstanceOf(Function); 16 | }); 17 | 18 | describe('Router', () => { 19 | let scratch; 20 | let router; 21 | 22 | beforeEach(() => { 23 | scratch = document.createElement('div'); 24 | document.body.appendChild(scratch); 25 | }); 26 | 27 | afterEach(() => { 28 | document.body.removeChild(scratch); 29 | router.componentWillUnmount(); 30 | }); 31 | 32 | it('should filter children based on URL', () => { 33 | let children = [ 34 | , 35 | , 36 | 37 | ]; 38 | 39 | render( (router = ref)}>{children}, scratch); 40 | 41 | expect( 42 | router.render({ children }, { url: '/foo' }).props.children 43 | ).toBeCloneOf(children[1]); 44 | 45 | expect( 46 | router.render({ children }, { url: '/' }).props.children 47 | ).toBeCloneOf(children[0]); 48 | 49 | expect( 50 | router.render({ children }, { url: '/foo/bar' }).props.children 51 | ).toBeCloneOf(children[2]); 52 | }); 53 | 54 | it('should support nested parameterized routes', () => { 55 | let children = [ 56 | , 57 | , 58 | 59 | ]; 60 | 61 | render( (router = ref)}>{children}, scratch); 62 | 63 | expect( 64 | router.render({ children }, { url: '/foo' }).props.children 65 | ).toBeCloneOf(children[0]); 66 | 67 | expect( 68 | router.render({ children }, { url: '/foo/bar' }).props.children 69 | ).toBeCloneOf(children[1], { matches: { bar: 'bar' }, url: '/foo/bar' }); 70 | 71 | expect( 72 | router.render({ children }, { url: '/foo/bar/baz' }).props.children 73 | ).toBeCloneOf(children[2], { 74 | matches: { bar: 'bar', baz: 'baz' }, 75 | url: '/foo/bar/baz' 76 | }); 77 | }); 78 | 79 | it('should support default routes', () => { 80 | let children = [, , ]; 81 | 82 | render( (router = ref)}>{children}, scratch); 83 | 84 | expect( 85 | router.render({ children }, { url: '/foo' }).props.children 86 | ).toBeCloneOf(children[2]); 87 | 88 | expect( 89 | router.render({ children }, { url: '/' }).props.children 90 | ).toBeCloneOf(children[1]); 91 | 92 | expect( 93 | router.render({ children }, { url: '/asdf/asdf' }).props.children 94 | ).toBeCloneOf(children[0], { matches: {}, url: '/asdf/asdf' }); 95 | }); 96 | 97 | it('should support initial route prop', () => { 98 | let children = [, , ]; 99 | 100 | render( 101 | (router = ref)}> 102 | {children} 103 | , 104 | scratch 105 | ); 106 | 107 | expect( 108 | router.render({ children }, router.state).props.children 109 | ).toBeCloneOf(children[2]); 110 | 111 | render(null, scratch); 112 | 113 | render( (router = ref)}>{children}, scratch); 114 | 115 | expect(router.state.url).toBe( 116 | location.pathname + (location.search || '') 117 | ); 118 | }); 119 | 120 | it('should support custom history', () => { 121 | let push = jasmine.createSpy('push'); 122 | let replace = jasmine.createSpy('replace'); 123 | let listen = jasmine.createSpy('listen'); 124 | let getCurrentLocation = jasmine 125 | .createSpy('getCurrentLocation', () => ({ pathname: '/initial' })) 126 | .and.callThrough(); 127 | 128 | let children = [ 129 | , 130 | , 131 | 132 | ]; 133 | 134 | render( 135 | (router = ref)} 138 | > 139 | {children} 140 | , 141 | scratch 142 | ); 143 | 144 | router.componentWillMount(); 145 | 146 | router.render(router.props, router.state); 147 | expect(getCurrentLocation).toHaveBeenCalledTimes(1); 148 | expect(router.state.url).toBe('/initial'); 149 | 150 | route('/foo'); 151 | expect(push).toHaveBeenCalledTimes(1); 152 | expect(push).toHaveBeenCalledWith('/foo'); 153 | 154 | route('/bar', true); 155 | expect(replace).toHaveBeenCalledTimes(1); 156 | expect(replace).toHaveBeenCalledWith('/bar'); 157 | 158 | router.componentWillUnmount(); 159 | }); 160 | 161 | it('should send proper params to Router.onChange', () => { 162 | let onChange = jasmine.createSpy('onChange'); 163 | 164 | let children = [ 165 | , 166 | , 167 | 168 | ]; 169 | 170 | render( 171 | (router = ref)}> 172 | {children} 173 | , 174 | scratch 175 | ); 176 | 177 | router.render(router.props, { url: '/' }); 178 | expect(onChange).toHaveBeenCalledWith( 179 | jasmine.objectContaining({ path: '/' }) 180 | ); 181 | 182 | router.render(router.props, { url: '/foo/67' }); 183 | expect(onChange).toHaveBeenCalledWith( 184 | jasmine.objectContaining({ path: '/foo/:id' }) 185 | ); 186 | 187 | router.render(router.props, { url: '/bar/bazparam/boo/bibiparam' }); 188 | expect(onChange).toHaveBeenCalledWith( 189 | jasmine.objectContaining({ path: '/bar/:baz/boo/:bibi' }) 190 | ); 191 | }); 192 | }); 193 | 194 | describe('route()', () => { 195 | let router; 196 | let scratch; 197 | 198 | beforeEach(() => { 199 | scratch = document.createElement('div'); 200 | document.body.appendChild(scratch); 201 | 202 | render( 203 | (router = ref)}> 204 | 205 | 206 | , 207 | scratch 208 | ); 209 | 210 | spyOn(router, 'routeTo').and.callThrough(); 211 | }); 212 | 213 | afterEach(() => { 214 | render(null, scratch); 215 | document.body.removeChild(scratch); 216 | }); 217 | 218 | it('should return true for existing route', () => { 219 | router.routeTo.calls.reset(); 220 | expect(route('/')).toBe(true); 221 | expect(router.routeTo).toHaveBeenCalledTimes(1); 222 | expect(router.routeTo).toHaveBeenCalledWith('/'); 223 | 224 | router.routeTo.calls.reset(); 225 | expect(route('/foo')).toBe(true); 226 | expect(router.routeTo).toHaveBeenCalledTimes(1); 227 | expect(router.routeTo).toHaveBeenCalledWith('/foo'); 228 | }); 229 | 230 | it('should return false for missing route', () => { 231 | router.routeTo.calls.reset(); 232 | expect(route('/asdf')).toBe(false); 233 | expect(router.routeTo).toHaveBeenCalledTimes(1); 234 | expect(router.routeTo).toHaveBeenCalledWith('/asdf'); 235 | }); 236 | 237 | it('should return true for fallback route', () => { 238 | let oldChildren = router.props.children; 239 | router.props.children = [, ...oldChildren]; 240 | 241 | router.routeTo.calls.reset(); 242 | expect(route('/asdf')).toBe(true); 243 | expect(router.routeTo).toHaveBeenCalledTimes(1); 244 | expect(router.routeTo).toHaveBeenCalledWith('/asdf'); 245 | }); 246 | }); 247 | 248 | describe('useRouter()', () => { 249 | let scratch; 250 | let router; 251 | 252 | it('should return route() as first param', () => { 253 | let useRouterValue; 254 | const C = () => { 255 | useRouterValue = useRouter(); 256 | return null; 257 | }; 258 | let scratch = document.createElement('div'); 259 | render(, scratch); 260 | expect(useRouterValue).toBeInstanceOf(Array); 261 | expect(useRouterValue[1]).toBeInstanceOf(Function); 262 | expect(useRouterValue[1]).toBe(route); 263 | render(null, scratch); 264 | }); 265 | 266 | it('should return valid router information', async () => { 267 | scratch = document.createElement('div'); 268 | document.body.appendChild(scratch); 269 | 270 | const FunctionalComponent = ({ path, shouldMatch }) => { 271 | const [ 272 | { router: routerFromHook, url, path: pathFromHook, matches } 273 | // eslint-disable-next-line react-hooks/rules-of-hooks 274 | ] = useRouter(); 275 | 276 | expect(routerFromHook).toBe(router); 277 | expect(url).toBe(router.state.url); 278 | expect(pathFromHook).toBe(path); 279 | 280 | Object.keys(shouldMatch).forEach(key => 281 | expect(matches[key]).toBe(shouldMatch[key]) 282 | ); 283 | 284 | return
; 285 | }; 286 | 287 | const children = [ 288 | , 292 | 296 | ]; 297 | 298 | render( (router = ref)}>{children}, scratch); 299 | 300 | route('/foo/45/barparam'); 301 | await sleep(1); 302 | 303 | route('/foo/bar/bazparam/bazz'); 304 | await sleep(1); 305 | 306 | document.body.removeChild(scratch); 307 | router.componentWillUnmount(); 308 | }); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /test/match.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Link, Match } from '../match'; 3 | 4 | function ChildComponent({}: {}) { 5 | return
; 6 | } 7 | 8 | function LinkComponent({}: {}) { 9 | return ( 10 |
11 | 12 | 13 |
14 | ); 15 | } 16 | 17 | function MatchComponent({}: {}) { 18 | return ( 19 | 20 | {({ matches, path, url }) => matches && } 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/router.tsx: -------------------------------------------------------------------------------- 1 | import { h, render, Component, FunctionalComponent } from 'preact'; 2 | import Router, { Route, RoutableProps, useRouter } from '../'; 3 | 4 | class ClassComponent extends Component<{}, {}> { 5 | render() { 6 | return
; 7 | } 8 | } 9 | 10 | const SomeFunctionalComponent: FunctionalComponent<{}> = ({}) => { 11 | return
; 12 | }; 13 | 14 | function RouterWithComponents() { 15 | return ( 16 | 17 |
18 | 19 | 20 |
21 | 22 | 23 |
24 | ); 25 | } 26 | 27 | function RouterWithRoutes() { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | function UseRouterFn() { 39 | const [{ active, current, matches, path, router, url, previous }, route] = 40 | useRouter(); 41 | 42 | const [{ matches: typedMatches }] = useRouter<{ id: string }>(); 43 | const id = typedMatches.id; 44 | 45 | route('/foo'); 46 | route({ url: '/bar', replace: true }); 47 | } 48 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "jsx": "react", 5 | "jsxFactory": "h" 6 | }, 7 | "files": ["router.tsx", "match.tsx", "../index.d.ts", "../match/index.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | exec, 3 | pathRankSort, 4 | prepareVNodeForRanking, 5 | segmentize, 6 | rank 7 | } from '../src/util'; 8 | 9 | const strip = str => segmentize(str).join('/'); 10 | 11 | describe('util', () => { 12 | describe('strip', () => { 13 | it('should strip preceeding slashes', () => { 14 | expect(strip('')).toBe(''); 15 | expect(strip('/')).toBe(''); 16 | expect(strip('/a')).toBe('a'); 17 | expect(strip('//a')).toBe('a'); 18 | expect(strip('//a/')).toBe('a'); 19 | }); 20 | 21 | it('should strip trailing slashes', () => { 22 | expect(strip('')).toBe(''); 23 | expect(strip('/')).toBe(''); 24 | expect(strip('a/')).toBe('a'); 25 | expect(strip('/a//')).toBe('a'); 26 | }); 27 | }); 28 | 29 | describe('rank', () => { 30 | it('should return rank of path segments', () => { 31 | expect(rank('')).toBe('5'); 32 | expect(rank('/')).toBe('5'); 33 | expect(rank('//')).toBe('5'); 34 | expect(rank('a/b/c')).toBe('555'); 35 | expect(rank('/a/b/c/')).toBe('555'); 36 | expect(rank('/:a/b?/:c?/:d*/:e+')).toEqual('45312'); 37 | }); 38 | }); 39 | 40 | describe('segmentize', () => { 41 | it('should split path on slashes', () => { 42 | expect(segmentize('')).toEqual(['']); 43 | expect(segmentize('/')).toEqual(['']); 44 | expect(segmentize('//')).toEqual(['']); 45 | expect(segmentize('a/b/c')).toEqual(['a', 'b', 'c']); 46 | expect(segmentize('/a/b/c/')).toEqual(['a', 'b', 'c']); 47 | }); 48 | }); 49 | 50 | describe('pathRankSort', () => { 51 | it('should sort by highest rank first', () => { 52 | let paths = arr => arr.map(path => ({ props: { path } })); 53 | let clean = vnode => { 54 | delete vnode.rank; 55 | delete vnode.index; 56 | return vnode; 57 | }; 58 | 59 | expect( 60 | paths(['/:a*', '/a', '/:a+', '/:a?', '/a/:b*']) 61 | .filter(prepareVNodeForRanking) 62 | .sort(pathRankSort) 63 | .map(clean) 64 | ).toEqual(paths(['/a/:b*', '/a', '/:a?', '/:a+', '/:a*'])); 65 | }); 66 | 67 | it('should return default routes last', () => { 68 | let paths = arr => arr.map(path => ({ props: { path } })); 69 | let clean = vnode => { 70 | delete vnode.rank; 71 | delete vnode.index; 72 | return vnode; 73 | }; 74 | 75 | let defaultPath = { props: { default: true } }; 76 | let p = paths(['/a/b/', '/a/b', '/', 'b']); 77 | p.splice(2, 0, defaultPath); 78 | 79 | expect( 80 | p.filter(prepareVNodeForRanking).sort(pathRankSort).map(clean) 81 | ).toEqual(paths(['/a/b/', '/a/b', '/', 'b']).concat(defaultPath)); 82 | }); 83 | }); 84 | 85 | describe('exec', () => { 86 | it('should match explicit equality', () => { 87 | expect(exec('/', '/', {})).toEqual({}); 88 | expect(exec('/a', '/a', {})).toEqual({}); 89 | expect(exec('/a', '/b', {})).toEqual(false); 90 | expect(exec('/a/b', '/a/b', {})).toEqual({}); 91 | expect(exec('/a/b', '/a/a', {})).toEqual(false); 92 | expect(exec('/a/b', '/b/b', {})).toEqual(false); 93 | }); 94 | 95 | it('should match param segments', () => { 96 | expect(exec('/', '/:foo', {})).toEqual(false); 97 | expect(exec('/bar', '/:foo', {})).toEqual({ foo: 'bar' }); 98 | }); 99 | 100 | it('should match optional param segments', () => { 101 | expect(exec('/', '/:foo?', {})).toEqual({ foo: '' }); 102 | expect(exec('/bar', '/:foo?', {})).toEqual({ foo: 'bar' }); 103 | expect(exec('/', '/:foo?/:bar?', {})).toEqual({ foo: '', bar: '' }); 104 | expect(exec('/bar', '/:foo?/:bar?', {})).toEqual({ foo: 'bar', bar: '' }); 105 | expect(exec('/bar', '/:foo?/bar', {})).toEqual(false); 106 | expect(exec('/foo/bar', '/:foo?/bar', {})).toEqual({ foo: 'foo' }); 107 | }); 108 | 109 | it('should match splat param segments', () => { 110 | expect(exec('/', '/:foo*', {})).toEqual({ foo: '' }); 111 | expect(exec('/a', '/:foo*', {})).toEqual({ foo: 'a' }); 112 | expect(exec('/a/b', '/:foo*', {})).toEqual({ foo: 'a/b' }); 113 | expect(exec('/a/b/c', '/:foo*', {})).toEqual({ foo: 'a/b/c' }); 114 | }); 115 | 116 | it('should match required splat param segments', () => { 117 | expect(exec('/', '/:foo+', {})).toEqual(false); 118 | expect(exec('/a', '/:foo+', {})).toEqual({ foo: 'a' }); 119 | expect(exec('/a/b', '/:foo+', {})).toEqual({ foo: 'a/b' }); 120 | expect(exec('/a/b/c', '/:foo+', {})).toEqual({ foo: 'a/b/c' }); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/utils/assert-clone-of.js: -------------------------------------------------------------------------------- 1 | import { cloneElement, toChildArray } from 'preact'; 2 | 3 | export function toBeCloneOf(util) { 4 | return { 5 | compare(actual, expected, opts) { 6 | const { matches = {}, url = expected.props.path } = opts || {}; 7 | const clonedRoute = cloneElement(expected, { url, matches, ...matches }); 8 | const result = {}; 9 | result.pass = util.equals(cleanVNode(actual), cleanVNode(clonedRoute)); 10 | result.message = `Expected ${serialize(actual)} ${ 11 | result.pass ? ' not' : '' 12 | }to equal ${serialize(clonedRoute)}`; 13 | return result; 14 | } 15 | }; 16 | } 17 | 18 | function cleanVNode(vnode) { 19 | const clone = Object.assign({}, vnode); 20 | delete clone.id; 21 | delete clone.constructor; 22 | delete clone.__v; 23 | return clone; 24 | } 25 | 26 | function serialize(vnode, prefix = '') { 27 | const type = typeof vnode.type === 'function' ? vnode.type.name : vnode.type; 28 | let str = `${prefix}<${type}`; 29 | let children; 30 | for (let prop in vnode.props) { 31 | const v = vnode.props[prop]; 32 | if (prop === 'children') { 33 | children = toChildArray(v).reduce( 34 | (str, v) => `${str}\n${serialize(v, `${prefix} `)}`, 35 | '' 36 | ); 37 | } else { 38 | str += ` ${prop}=${JSON.stringify(v)}`; 39 | } 40 | } 41 | if (children) { 42 | str += `${children}\n${prefix}`; 43 | } else { 44 | str += ' />'; 45 | } 46 | return str; 47 | } 48 | --------------------------------------------------------------------------------