├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.spec.ts ├── index.ts ├── package-lock.json ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .nyc_output 3 | coverage 4 | docs 5 | module 6 | node_modules 7 | index.js 8 | index.js.map 9 | index.d.ts 10 | *.iml 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .nyc_output 3 | coverage 4 | node_modules 5 | .gitignore 6 | .travis.yml 7 | tsconfig.json 8 | *.iml 9 | *.ts 10 | !*.d.ts 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | branches: 5 | only: 6 | - master 7 | install: 8 | - npm install 9 | - npm install coveralls 10 | script: 11 | - npm run build 12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | ## 1.1.1 4 | * Bugfix for double ignore event dispatch on same route navigation. 5 | ## 1.0.1 6 | * Fix packaging. 7 | ## 1.0.0 8 | * Update to storeon@2.0.1 9 | * Reimplementing in typescript 10 | #### Breaking changes 11 | * module export name changed from `asyncRoutingModule` to `routingModule` 12 | * events consts names changed eg from `ENDED_EVENT` to `NAVIGATION_ENDED_EVENT` 13 | ## 0.3.3 14 | * Bump dev dependencies because of security reasons 15 | * Updated documentation 16 | ## 0.3.2 17 | * Fixed typescript declaration, allows lest strict state to be provided to operation functions 18 | ## 0.3.1 19 | * Fixed typescript declaration, updated dependencies to latest typescript 20 | ## 0.1.2 21 | * Added samples in readme 22 | ## 0.1.1 23 | * Removed dependencies 24 | ## 0.1 25 | * Initial release. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2019 Pawel Majewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # storeon-async-router 2 | 3 | [![npm version](https://badge.fury.io/js/storeon-async-router.svg)](https://badge.fury.io/js/storeon-async-router) 4 | [![Build Status](https://travis-ci.org/majo44/storeon-async-router.svg?branch=master)](https://travis-ci.org/majo44/storeon-async-router) 5 | [![Coverage Status](https://coveralls.io/repos/github/majo44/storeon-async-router/badge.svg?branch=master)](https://coveralls.io/github/majo44/storeon-async-router?branch=master) 6 | 7 | Storeon logo by Anton Lovchikov 9 | 10 | Asynchronous router for [Storeon](https://github.com/storeon/storeon). 11 | 12 | It size is ~1kB (minified and gzipped) and uses [Size Limit](https://github.com/ai/size-limit) to control size. 13 | 14 | ### Overview 15 | The key features are: 16 | * allows **async** route handlers for prefetch the data or lazy loading of modules 17 | * support for **abort** the routing if there was some navigation cancel eg. by fast clicking 18 | * allows **update** routing definition in fly (eg, when you are loading some self module lazy which should add 19 | self controlled routes). 20 | * **ignores** same routes navigation 21 | 22 | This router is implementation of idea of **state first routing**, which at first place reflects the 23 | navigation within the state, and reflection within the UI stay on application side. 24 | Also this library is decoupled from browser history. 25 | Examples of integration with browser history or UI code you can find in recipes. 26 | 27 | ### Install 28 | > npm i storeon-async-router --save 29 | 30 | ### Requirements 31 | * this library internally use [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), 32 | so for legacy browsers and for node.js you will need to use 33 | [abortcontroller-polyfill](https://www.npmjs.com/package/abortcontroller-polyfill). Please 34 | refer to [abortcontroller-polyfill](https://www.npmjs.com/package/abortcontroller-polyfill) documentation, as it is requires 35 | also polyfilles for promise (on IE) and fetch (Node and IE). 36 | 37 | ### Usage 38 | 39 | ```javascript 40 | import { createStoreon } from "storeon"; 41 | import { routingModule, onNavigate, navigate } from "storeon-async-router"; 42 | 43 | // create store with adding route module 44 | const store = createStoreon([routingModule]); 45 | 46 | // handle data flow events 47 | store.on("dataLoaded", (state, data) => ({ data })); 48 | 49 | // repaint state 50 | store.on("@changed", state => { 51 | document.querySelector(".out").innerHTML = state.routing.next 52 | ? `Loading ${state.routing.next.url}` 53 | : JSON.stringify(state.data); 54 | }); 55 | 56 | // register some route handle 57 | onNavigate(store, "/home/(?.*)", async (navigation, signal) => { 58 | // preload data 59 | const homePageData = await fetch(`${navigation.params.page}.json`, { 60 | signal 61 | }).then(response => response.json()); 62 | // dispatch data to store 63 | store.dispatch("dataLoaded", homePageData); 64 | }); 65 | 66 | // map anchors href to navigation event 67 | document.querySelectorAll("a").forEach((anchor, no) => 68 | anchor.addEventListener("click", e => { 69 | e.preventDefault(); 70 | navigate(store, anchor.getAttribute("href")); 71 | }) 72 | ); 73 | ``` 74 | [![Edit storeon-async-router-simple-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-routersample1-r1ey6?fontsize=14) 75 | 76 | Or visit working [demo](https://r1ey6.codesandbox.io/) and try to run with Redux development tools, and 77 | try to fast click with http throttling, to see the navigation cancellation. 78 | 79 | 80 | ### Api 81 | - `routingModule` - is storeon module which contains the whole logic of routing 82 | - this module contains reducer for the `routing` state property which contains: 83 | - `current` current applied `Navigation` 84 | - `next` ongoing `Navigation` (if there is any) 85 | - `onNavigate(store, route, callback)` - function which registers route callback, on provided store 86 | for provided route (path regexp string). Callback is a function which will be called if route will be matched, 87 | Important think is that last registered handle have a higher 88 | priority, so if at the end you will register multiple handle for same route, 89 | only the last registered one will be used. `onNavigate` is returns function which can be used for 90 | unregister the handle. Params: 91 | - `store` instance of store 92 | - `route` the route path regexp string, please notice that only path is matched and can contains the rote params, 93 | If you want to read search params you have to do that in callback by parsing `url` string delivered there in 94 | `navigation` object. On modern browsers you can use regexp group namings for path params. 95 | - `callback` the callback which will be called when provided route will be matched with requested url. 96 | `callback` can returns undefined or promise. In case of promise, route will be not applied (navigation will be not 97 | ended) until the promise will be not resolve. Callback is called with two parameters: 98 | - `navigation` ongoing `Navigation` object 99 | - `signal` which is [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal), 100 | to be notified that current processed navigation was cancelled. That parameter can be used directly on 101 | calls of [fetch](https://developers.google.com/web/updates/2017/09/abortable-fetch) api. 102 | - `navigate(store, url, [force], [options])` - function which triggers navigation to particular url. Params: 103 | - `store` instance of store 104 | - `url` requested url string 105 | - `force` optional force navigation, if there is a registered route which will match the requested url, even for same url 106 | as current the route callback will be called 107 | - `options` optional additional navigation options which will be delivered to route callback 108 | for browser url navigation it can be eg. replace - for replacing url in the url bar, ect. 109 | - `cancelNavigation(store)` - function which cancel current navigation (if there is any in progress). Params: 110 | - `store` instance of store 111 | - `Navigation` object contains 112 | - `url` requested url string 113 | - `id` unique identifier of navigation 114 | - `options` additional options for navigation, for browser url navigation it can be 115 | eg. replace - for replacing url in the url bar, ect.. 116 | - `force` force the navigation, for the cases when even for same url as current have to be handled 117 | - `params` map of route parameters values (handled by matched route regexp grops) 118 | - `route` the route which handled that navigation 119 | 120 | ### Recipes 121 | 122 | #### Redirection 123 | Redirection of navigation from one route handler to another route. 124 | ```javascript 125 | // example of redirection from page to page 126 | // the last registered route handle have a bigger priority then previous one 127 | onNavigate(store, "/home/1", () => navigate(store, '/home/2')); 128 | ``` 129 | [![Edit storeon-async-router-redirection-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-router-simple-sample-mp91n?fontsize=14) 130 | 131 | 132 | #### "Otherwise" Redirection 133 | 134 | The very special case is "otherwise" route, such route is covers all uncovered routes and handler of such route 135 | should simply redirect navigation to well known route. 136 | Please remember also that "otherwise" route should be registered as a very first, as in [storeon-async-router] the 137 | highest priority has last registered routes. 138 | 139 | ```javascript 140 | // example of "otherwise" redirection 141 | // so for any unhandled route, we will redirect to '/404' route 142 | onNavigate(store, "", () => navigate(store, '/404')); 143 | ``` 144 | 145 | #### Async route handle 146 | ##### Preloading the data 147 | For case when before of navigation we want to preload some data, we can use async route handle and postpone the navigation. 148 | We can use abort signal for aborting the ongoing fetch. 149 | ```javascript 150 | // register async route handle 151 | onNavigate(store, "/home/(?.*)", async (navigation, signal) => { 152 | // retrieve the data from server, 153 | // we are able to use our abort signal for fetch cancellation 154 | // please notice that on cancel, fetch will throw AbortError 155 | // which will stop the flow but this error will be handled on router level 156 | const homePageData = await fetch(`${navigation.params.page}.json`, { 157 | signal 158 | }).then(response => response.json()); 159 | // dispatch data to store 160 | store.dispatch("dataLoaded", homePageData); 161 | }); 162 | ``` 163 | [![Edit storeon-async-router-simple-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-routersample1-r1ey6?fontsize=14) 164 | 165 | Please notice that used in example [RegExp named capture groups](http://2ality.com/2017/05/regexp-named-capture-groups.html) 166 | (like `/home/(?.*)`) are part of ES2018 standard, and this syntax is not supported yet on 167 | [all browsers](https://kangax.github.io/compat-table/es2016plus/#test-RegExp_named_capture_groups). As a alternative you 168 | can refer the parameters by the order no, so instead of `navigation.params.page` you can use `navigation.params[0]`. 169 | 170 | ##### Lazy loading of submodule 171 | For application code splitting we can simple use es6 `import()` function. In case when you will want to spilt your by the 172 | routes, you can simple do that with async router. What you need to do is just await for `import()` your lazy module within the 173 | route handle. You can additionally extend your routing within the loaded module. 174 | 175 | ```javascript 176 | // ./app.js 177 | // example of lazy loading 178 | // register the navigation to admin page, but keeps reference to unregister function 179 | const unRegister = onNavigate( 180 | store, 181 | "/admin", 182 | async (navigation, abortSignal) => { 183 | // preload some lazy module 184 | const adminModule = await import("./adminModule.js"); 185 | // check that navigation was not cancelled 186 | // as dynamic import is not support cancelation itself like fetch api 187 | if (!abortSignal.aborted) { 188 | // unregister app level route handle for that route 189 | // the lazy module will take by self control over the internal routing 190 | unRegister(); 191 | // init module, here we will register event handlers on storeon in 192 | // lazy loaded module 193 | adminModule.adminModule(store); 194 | // navigate once again (with force flag) to trigger the route handle from 195 | // lazy loaded module 196 | navigate(store, navigation.url, true); 197 | } 198 | } 199 | ); 200 | ``` 201 | 202 | ```javascript 203 | // ./adminModule.js 204 | /** 205 | * Function which is responsible for initialize the lazy loaded module 206 | */ 207 | export function adminModule(store) { 208 | // registering own routing handler for the route of my module 209 | onNavigate(store, "/admin", async (navigation, signal) => { 210 | // preload data 211 | const adminPageData = await fetch(`admin.json`, { 212 | signal 213 | }).then(response => response.json()); 214 | // const homePageData = await homePageDataResponse.json(); 215 | // dispatch data to store 216 | store.dispatch("dataLoaded", adminPageData); 217 | }); 218 | } 219 | 220 | ``` 221 | [![Edit storeon-async-router-lazy-load-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-router-redirection-sample-h3p66?fontsize=14) 222 | 223 | #### Integration with browser history 224 | In order to synchronize the routing state within the store with the browser history (back/forward, location) 225 | we can simple connect the store with browser history object by fallowing code: 226 | 227 | ```javascript 228 | // returns full url 229 | function getLocationFullUrl() { 230 | // we are building full url here, but if you care in your app only on 231 | // path you can simplify that code, and return just window.location.pathname 232 | return ( 233 | window.location.pathname + 234 | (window.location.search ? window.location.search : "") + 235 | (window.location.hash ? window.location.hash : "") 236 | ); 237 | } 238 | 239 | // on application start navigate to current url 240 | setTimeout(() => { 241 | navigate(store, getLocationFullUrl(), false, { replace: true }); 242 | }); 243 | 244 | // connect with back/forwad of browser history 245 | window.addEventListener("popstate", () => { 246 | navigate(store, getLocationFullUrl()); 247 | }); 248 | 249 | // connecting store changes to browser history 250 | store.on(NAVIGATE_ENDED_EVENT, async (state, navigation) => { 251 | // ignore url's from popstate 252 | if (getLocationFullUrl() !== navigation.url) { 253 | navigation.options && navigation.options.replace 254 | ? window.history.replaceState({}, "", navigation.url) 255 | : window.history.pushState({}, "", navigation.url); 256 | } 257 | }); 258 | ``` 259 | [![Edit storeon-async-router-browser-history-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-router-lazy-load-sample-r9pz0?fontsize=14) 260 | 261 | Please remember that with such solution you should probably also set in your html document head `` 262 | 263 | #### Handling the anchor click events globally 264 | To handle any html anchor click over the page you cansimple create global click handler like that: 265 | ```javascript 266 | // on body level 267 | document.body.addEventListener("click", function(event) { 268 | // handle anchors click, ignore external, and open in new tab 269 | if ( 270 | !event.defaultPrevented && 271 | event.target.tagName === "A" && 272 | event.target.href.indexOf(window.location.origin) === 0 && 273 | event.target.target !== "_blank" && 274 | event.button === 0 && 275 | event.which === 1 && 276 | !event.metaKey && 277 | !event.ctrlKey && 278 | !event.shiftKey && 279 | !event.altKey 280 | ) { 281 | event.preventDefault(); 282 | const path = event.target.href.slice(window.location.origin.length); 283 | navigate(store, path); 284 | } 285 | }); 286 | ``` 287 | [![Edit storeon-async-router-global-anchor-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-router-browser-history-sample-sybtj?fontsize=14) 288 | 289 | #### Encapsulate routing to shared router object 290 | If you do not want always to deliver store to utility functions you can simple encapsulate all functionality to single 291 | router object. 292 | ```javascript 293 | import createStore from 'storeon'; 294 | import { asyncRoutingModule, onNavigate, navigate, cancelNavigation } from 'storeon-async-router'; 295 | 296 | // create store with adding route module 297 | const store = createStore([asyncRoutingModule]); 298 | // router factory 299 | function routerFactory(store) { 300 | return { 301 | get current() { 302 | return store.get().routing.current; 303 | }, 304 | navigate: navigate.bind(null, store), 305 | onNavigate: onNavigate.bind(null, store) 306 | } 307 | } 308 | // router instance 309 | const router = routerFactory(store); 310 | // adding handle 311 | router.onNavigate('/home', () => {}); 312 | // navigate to url 313 | router.navigate('/home'); 314 | ``` 315 | [![Edit storeon-async-router-router-object-sample](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/storeon-async-router-global-anchor-sample-e7q66?fontsize=14) 316 | 317 | ### Internal data flow 318 | 1. user registers the handles by usage of `onNavigate` (can do this in stereon module, but within the @init callback), 319 | 320 | 1.1 for each registered handle we generating unique `id`, 321 | 322 | 1.2 cache the handle under that `id`, and dispatch `route register` event with provided route and handle `id` 323 | 324 | 2. on `route register` we are storing in state provided route and id (at the top of stack) 325 | 3. on `navigate` event 326 | 327 | 3.1. we checking exit conditions (same route, or same route navigation in progres), 328 | 329 | 3.2. if there is any ongoing navigation we are dispatch `navigation cancel` event 330 | 331 | 3.3. then we are setting the `next` navigation in state, 332 | 333 | 3.4. asynchronously dispatch `before navigation` event 334 | 335 | 4. on `before navigation` event 336 | 337 | 4.1 we are looking in state for handle `id` which route matches requested url, by the matched `id` we are taking the 338 | handle from cache, 339 | 340 | 4.2. we creates AbortController from which we are taking the AbortSignal, 341 | 342 | 4.3. we attach to storeon handle for `navigation canceled` event to call `cancell` on AbortController 343 | 344 | 4.4. we call handle with details of navigation and abortSignal, if the result of handle call is Promise, we are waits to 345 | resolve, 346 | 347 | 4.5 we are dispatch `navigation end` event, and unregister `navigation canceled` handle 348 | 349 | 5. on `navigation canceled` we are clear the `next` navigation in state 350 | 6. on `navigation end` we move `next` to `current` ins state 351 | -------------------------------------------------------------------------------- /index.spec.ts: -------------------------------------------------------------------------------- 1 | import { createStoreon, StoreonStore } from 'storeon'; 2 | import { 3 | onNavigate, 4 | routingModule, 5 | navigate, 6 | cancelNavigation, 7 | StateWithRouting, RoutingEvents, PRE_NAVIGATE_EVENT, NAVIGATION_IGNORED_EVENT 8 | } from './index'; 9 | import * as sinon from 'sinon'; 10 | import { expect, use } from 'chai'; 11 | import * as sinonChai from "sinon-chai"; 12 | import * as chaiAsPromised from "chai-as-promised"; 13 | import * as nodeFetch from 'node-fetch'; 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | (global as any).fetch = nodeFetch; 16 | import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'; 17 | 18 | use(sinonChai); 19 | use(chaiAsPromised); 20 | 21 | describe(`simple scenarions`, () => { 22 | 23 | let store: StoreonStore; 24 | 25 | beforeEach(() => { 26 | store = createStoreon([routingModule, 27 | // (store) => { 28 | // store.on('@dispatch', (s, e) => { 29 | // if (e[0] !== '@changed') { 30 | // console.log('event', e[0], (e[1] as any)?.navigation?.id, (e[1] as any)?.navigation?.url, (e[1] as any)?.type) 31 | // } 32 | // }); 33 | // store.on('@changed', (s) => { 34 | // console.log('state', 35 | // `current: ${s.routing?.current?.id}/${s.routing?.current?.url}`, 36 | // `next: ${s.routing?.next?.id}/${s.routing?.next?.url}`) 37 | // }) 38 | // } 39 | ]); 40 | }); 41 | 42 | it(`Router should call handle for proper registered route`, async () => { 43 | const spy = sinon.fake(); 44 | onNavigate(store, '/', spy); 45 | await navigate(store, '/a'); 46 | expect(store.get().routing.current.url).eq('/a'); 47 | expect(spy).to.be.calledOnce; 48 | expect(spy.getCall(0).args[0].url).eq('/a'); 49 | expect(spy.getCall(0).args[0].route).eq('/'); 50 | }); 51 | 52 | it(`Router should ignore second navigation for same url`, async () => { 53 | const spy = sinon.fake.returns(Promise.resolve()); 54 | onNavigate(store, '/a', spy); 55 | await Promise.all([ 56 | navigate(store, '/a'), 57 | navigate(store, '/a')]); 58 | expect(spy).to.be.calledOnce; 59 | }); 60 | 61 | it(`Router should ignore navigation for same url as current`, async () => { 62 | const spy = sinon.fake(); 63 | onNavigate(store, '/a', spy); 64 | await navigate(store, '/a'); 65 | expect(spy).to.be.calledOnce; 66 | await navigate(store, '/a'); 67 | expect(spy).to.be.calledOnce; 68 | }); 69 | 70 | 71 | it(`Router should cancel previous navigation immediately if navigation occurs just after previous`, async () => { 72 | const spy = sinon.fake(); 73 | onNavigate(store, '/a', spy); 74 | await Promise.all([ 75 | navigate(store, '/a/1'), 76 | navigate(store, '/a/2'), 77 | ]); 78 | expect(spy).to.be.calledOnce; 79 | expect(spy.getCall(0).args[0].url).eq('/a/2'); 80 | expect(spy.getCall(0).args[0].route).eq('/a'); 81 | expect(store.get().routing.current.url).eq('/a/2'); 82 | expect(store.get().routing.current.route).eq('/a'); 83 | }); 84 | 85 | it(`Route should cancel previous navigation navigation occurs during previous`, async () => { 86 | const spy = sinon.fake(); 87 | let semaphor; 88 | onNavigate(store, '/a', async (n, s) => { 89 | semaphor = navigate(store, '/b'); 90 | await semaphor; 91 | expect(s.aborted); 92 | }); 93 | onNavigate(store, '/b', spy); 94 | await navigate(store, '/a'); 95 | await semaphor; 96 | expect(spy).to.be.calledOnce; 97 | expect(spy.getCall(0).args[0].url).eq('/b'); 98 | expect(spy.getCall(0).args[0].route).eq('/b'); 99 | expect(store.get().routing.current.url).eq('/b'); 100 | expect(store.get().routing.current.route).eq('/b'); 101 | }); 102 | 103 | it('Router should throw error if the navigation occurs for not registered route', async () => { 104 | await expect(navigate(store, '/a')).to.be.rejected; 105 | }); 106 | 107 | it('Router should throw error if the navigation handle throws error', async () => { 108 | onNavigate(store, '', () => { 109 | throw new Error('Error'); 110 | }); 111 | await expect(navigate(store, '/')).to.be.rejected; 112 | }); 113 | 114 | it('Router should allows to replace the handle of route on the fly', async () => { 115 | const spy1 = sinon.fake(); 116 | const spy2 = sinon.fake(); 117 | onNavigate(store, '', spy1); 118 | await navigate(store, '/1'); 119 | expect(spy1).to.be.calledOnce; 120 | const un2 = onNavigate(store, '', spy2); 121 | await navigate(store, '/2'); 122 | expect(spy1).to.be.calledOnce; 123 | expect(spy2).to.be.calledOnce; 124 | un2(); 125 | await navigate(store, '/3'); 126 | expect(spy2).to.be.calledOnce; 127 | expect(spy1).to.be.calledTwice; 128 | }); 129 | 130 | it('Router should allows to cancel sync navigation', async () => { 131 | // eslint-disable-next-line @typescript-eslint/no-empty-function 132 | onNavigate(store, '/a', () => {}); 133 | onNavigate(store, '/b', () => { 134 | cancelNavigation(store); 135 | }); 136 | 137 | await navigate(store, '/a'); 138 | await navigate(store, '/b'); 139 | expect(store.get().routing.current.url).eq('/a'); 140 | expect(store.get().routing.current.route).eq('/a'); 141 | 142 | }); 143 | 144 | it('Router should allow to cancel navigation in PRE_NAVIGATE_EVENT', async () => { 145 | // eslint-disable-next-line @typescript-eslint/no-empty-function 146 | onNavigate(store, '/a', () => {}); 147 | onNavigate(store, '/b', () => {}); 148 | 149 | store.on(PRE_NAVIGATE_EVENT,(_, {navigation}) => { 150 | if(navigation.url === '/b') { 151 | cancelNavigation(store); 152 | } 153 | }) 154 | 155 | await navigate(store, '/a'); 156 | await navigate(store, '/b'); 157 | 158 | expect(store.get().routing.current.url).eq('/a'); 159 | expect(store.get().routing.current.route).eq('/a'); 160 | }); 161 | 162 | it('Router should allows to cancel async navigation', async () => { 163 | let continueA: () => void; 164 | // eslint-disable-next-line @typescript-eslint/no-empty-function 165 | onNavigate(store, '/a', () => {}); 166 | onNavigate(store, '/b', () => { 167 | return new Promise(res => continueA = res) 168 | }); 169 | 170 | await navigate(store, '/a'); 171 | 172 | const promise = navigate(store, '/b'); 173 | setTimeout(() => { 174 | cancelNavigation(store); 175 | continueA(); 176 | }); 177 | await promise; 178 | expect(store.get().routing.current.url).eq('/a'); 179 | expect(store.get().routing.current.route).eq('/a'); 180 | 181 | }); 182 | 183 | it('Router should allows for redirection', async () => { 184 | // eslint-disable-next-line @typescript-eslint/no-empty-function 185 | onNavigate(store, '/a', () => {}); 186 | onNavigate(store, '/b', () => { 187 | return navigate(store, '/a'); 188 | }); 189 | await navigate(store, '/b'); 190 | expect(store.get().routing.current.url).eq('/a'); 191 | expect(store.get().routing.current.route).eq('/a'); 192 | }); 193 | 194 | it('Router should ignore AbortError', async () => { 195 | const spy = sinon.fake(); 196 | let continueSemaphore: () => void; 197 | const semaphore = new Promise(res => continueSemaphore = res); 198 | onNavigate(store, '/a', async (navigation, signal) => { 199 | continueSemaphore(); 200 | await fetch('http://slowwly.robertomurray.co.uk/delay/3000/url/http://www.google.co.uk', {signal}); 201 | spy(); 202 | }); 203 | navigate(store, '/a'); 204 | await semaphore; 205 | cancelNavigation(store); 206 | expect(spy).not.called; 207 | }); 208 | 209 | it('Should provide proper parameter values', async () => { 210 | const spy = sinon.fake(); 211 | onNavigate(store, '/a/(?.*)', spy); 212 | await navigate(store, '/a/test'); 213 | expect(store.get().routing.current.params).eql({page: 'test', 0: 'test'}); 214 | }); 215 | 216 | it('regression - when ignore navigation ignore event should be dispatched once', async () => { 217 | const spy = sinon.fake(); 218 | let count = 0; 219 | store.on('@dispatch', (s, e) => { 220 | if (e[0] === NAVIGATION_IGNORED_EVENT) { 221 | count ++; 222 | } 223 | }); 224 | onNavigate(store, '/a', spy); 225 | await navigate(store, '/a'); 226 | expect(spy).to.be.calledOnce; 227 | await navigate(store, '/a'); 228 | expect(spy).to.be.calledOnce; 229 | await new Promise((res) => setTimeout(res)); 230 | expect(count).to.eq(1); 231 | }) 232 | 233 | }); 234 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { StoreonStore } from 'storeon'; 2 | 3 | /** 4 | * Navigation represents ongoing navigation. 5 | */ 6 | export interface Navigation { 7 | /** 8 | * Unique identifier of navigation 9 | */ 10 | id: number; 11 | /** 12 | * Requested url. 13 | */ 14 | url: string; 15 | /** 16 | * Force the navigation, for the cases when even for same url as current have to be handled. 17 | */ 18 | force?: boolean; 19 | /** 20 | * Additional options for navigation, for browser url navigation it can be 21 | * eg. replace - for replacing url in the url bar, ect.. 22 | */ 23 | options?: any; 24 | } 25 | 26 | export interface NavigationState extends Navigation { 27 | 28 | /** 29 | * Url params. For the case when provided route regexp 30 | * contains some parameters groups. 31 | */ 32 | params?: {[key: string]: string}; 33 | /** 34 | * Route expression which matched that navigation. 35 | */ 36 | route: string; 37 | } 38 | 39 | export interface StateWithRouting { 40 | /** 41 | * Routing state. 42 | */ 43 | readonly routing: { 44 | /** 45 | * Registered handlers ids. 46 | */ 47 | readonly handles: Array<{id: number; route: string}>; 48 | /** 49 | * Current state of navigation. 50 | */ 51 | readonly current?: NavigationState; 52 | /** 53 | * The navigation which is in progress. 54 | */ 55 | readonly next?: Navigation; 56 | /** 57 | * The navigation which is in progress. 58 | */ 59 | readonly candidate?: Navigation; 60 | }; 61 | } 62 | 63 | /** 64 | * Callback for route navigation 65 | */ 66 | export type RouteCallback = 67 | (navigation: Navigation, signal: AbortSignal) => (void | Promise); 68 | 69 | /** 70 | * Registered routes cache. 71 | */ 72 | const routes: {[key: number]: {id: number; route: string; regexp: RegExp; callback: RouteCallback}} = {}; 73 | 74 | /** 75 | * Next handle id. 76 | * @type {number} 77 | */ 78 | let handleId = 0; 79 | 80 | /** 81 | * Next navigation id. 82 | * @type {number} 83 | */ 84 | let navId = 0; 85 | 86 | 87 | /** 88 | * Event dispatched when handler is registered to route. 89 | */ 90 | export const REGISTER_EVENT = Symbol('REGISTER_ROUTE'); 91 | /** 92 | * Event dispatched when handler is unregistered. 93 | */ 94 | export const UNREGISTER_EVENT = Symbol('UNREGISTER_ROUTE'); 95 | /** 96 | * Event dispatched before navigation. 97 | */ 98 | export const PRE_NAVIGATE_EVENT = Symbol('PRE_NAVIGATE_EVENT'); 99 | /** 100 | * Event dispatched to start navigation. 101 | */ 102 | export const NAVIGATE_EVENT = Symbol('NAVIGATE'); 103 | /** 104 | * Event dispatched after end of navigation. 105 | */ 106 | export const POST_NAVIGATE_EVENT = Symbol('POST_NAVIGATE_EVENT'); 107 | /** 108 | * Event dispatched when navigation is ended successfully. 109 | */ 110 | export const NAVIGATION_ENDED_EVENT = Symbol('NAVIGATION_ENDED'); 111 | /** 112 | * Event dispatched when navigation is failed. 113 | */ 114 | export const NAVIGATION_FAILED_EVENT = Symbol('NAVIGATION_FAILED'); 115 | /** 116 | * Event dispatched when navigation is cancelled. 117 | */ 118 | export const NAVIGATION_CANCELLED_EVENT = Symbol('NAVIGATE_CANCELLED'); 119 | /** 120 | * Event dispatched when navigation is ignored. 121 | */ 122 | export const NAVIGATION_IGNORED_EVENT = Symbol('NAVIGATE_IGNORED'); 123 | /** 124 | * Event dispatched when navigation have to be cancelled. 125 | */ 126 | export const CANCEL_EVENT = Symbol('CANCEL_EVENT'); 127 | 128 | export interface NavigationEvent { 129 | navigation: Navigation; 130 | } 131 | 132 | export interface RoutingEvents { 133 | [REGISTER_EVENT]: { id: number; route: string }; 134 | [UNREGISTER_EVENT]: { id: number; route: string }; 135 | [PRE_NAVIGATE_EVENT]: NavigationEvent; 136 | [NAVIGATE_EVENT]: NavigationEvent; 137 | [NAVIGATION_ENDED_EVENT]: {navigation: NavigationState}; 138 | [NAVIGATION_FAILED_EVENT]: {navigation: Navigation; error: any }; 139 | [NAVIGATION_CANCELLED_EVENT]: undefined; 140 | [NAVIGATION_IGNORED_EVENT]: NavigationEvent; 141 | [POST_NAVIGATE_EVENT]: {navigation: Navigation; error?: any }; 142 | [CANCEL_EVENT]: undefined; 143 | } 144 | 145 | const ignoreNavigation = (navigation: Navigation, {current, next}: StateWithRouting['routing']) => 146 | // if it is not forced and 147 | // if is for same url and not forced or 148 | // if the navigation is to same url as current 149 | !navigation.force && (next?.url === navigation.url || current?.url === navigation.url); 150 | 151 | /** 152 | * Storeon router module. Use it during your store creation. 153 | * 154 | * @example 155 | * import createStore from 'storeon'; 156 | * import { asyncRoutingModule } from 'storeon-async-router; 157 | * const store = createStore([asyncRoutingModule, your_module1 ...]); 158 | */ 159 | export const routingModule = (store: StoreonStore) => { 160 | 161 | const dispatch = store.dispatch.bind(store); 162 | const on = store.on.bind(store); 163 | 164 | /** 165 | * Set default state on initialization. 166 | */ 167 | on('@init', () => ({ routing: { handles: [] } })); 168 | 169 | // if the navigation have not to be ignored, set is as candidate 170 | on(PRE_NAVIGATE_EVENT, ({ routing }, { navigation }) => { 171 | if (ignoreNavigation(navigation, routing)) { 172 | // we will ignore this navigation request 173 | dispatch(NAVIGATION_IGNORED_EVENT, {navigation}); 174 | return; 175 | } 176 | 177 | setTimeout(() => { 178 | if (store.get().routing.candidate?.id === navigation.id) { 179 | dispatch(NAVIGATE_EVENT, { navigation }) 180 | } else { 181 | dispatch(NAVIGATION_IGNORED_EVENT, {navigation}); 182 | } 183 | }); 184 | 185 | return { 186 | routing: { 187 | ...routing, 188 | candidate: navigation, 189 | }, 190 | }; 191 | }); 192 | 193 | // if we have something ongoing 194 | // we have to cancel them 195 | on(NAVIGATE_EVENT, ({ routing }) => { 196 | if (routing.next) { 197 | dispatch(NAVIGATION_CANCELLED_EVENT) 198 | } 199 | }); 200 | 201 | // set new ongoing next navigation 202 | on(NAVIGATE_EVENT, ({ routing }) => ({ 203 | routing: { 204 | ...routing, 205 | next: routing.candidate, 206 | candidate: null 207 | } 208 | })); 209 | 210 | // proceed ongoing navigation 211 | on( 212 | NAVIGATE_EVENT, async ({routing}, {navigation: n}) => { 213 | 214 | let match: RegExpMatchArray = undefined; 215 | let route = ''; 216 | 217 | // looking for handle which match navigation 218 | const handle = routing.handles.find(({ id }) => { 219 | match = n.url.match(routes[id].regexp); 220 | ({ route } = routes[id]); 221 | return !!match; 222 | }); 223 | 224 | // if there is no matched route, that is something wrong 225 | if (!handle || !match) { 226 | const error = new Error(`No route handle for url: ${n.url}`); 227 | dispatch(NAVIGATION_FAILED_EVENT,{ navigation: n, error }); 228 | return; 229 | } 230 | 231 | // prepare navigation state 232 | const navigation: NavigationState = { 233 | ...n, 234 | route, 235 | params: { 236 | ...(match.groups), 237 | ...(match).splice(1).reduce( 238 | (r, g, i) => ({...r, [i.toString(10)]: g}), {}), 239 | }, 240 | }; 241 | // taking callback for matched route 242 | const { callback } = routes[handle.id]; 243 | // allows to cancellation 244 | const ac = new AbortController(); 245 | const disconnect = on(NAVIGATION_CANCELLED_EVENT, () => ac.abort()); 246 | try { 247 | // call callback 248 | const res = callback(navigation, ac.signal); 249 | // waits for the result 250 | await res; 251 | if (!ac.signal.aborted) { 252 | // if was not cancelled, confirm end of navigation 253 | dispatch(NAVIGATION_ENDED_EVENT, {navigation}); 254 | } 255 | dispatch(POST_NAVIGATE_EVENT, {navigation}); 256 | } catch (error) { 257 | if (error.name !== 'AbortError') { 258 | // on any error 259 | dispatch(NAVIGATION_FAILED_EVENT, {navigation, error}); 260 | } 261 | } finally { 262 | // at the end disconnect cancellation 263 | disconnect(); 264 | } 265 | }, 266 | ); 267 | 268 | // state updates 269 | on(NAVIGATION_CANCELLED_EVENT, ({ routing }) => ({routing : { ...routing, candidate: undefined, next: undefined }})); 270 | on(NAVIGATION_FAILED_EVENT, ({ routing }) => ({routing : { ...routing, candidate: undefined, next: undefined }})); 271 | on(NAVIGATION_ENDED_EVENT, ({ routing }, {navigation}) => 272 | ({routing : { ...routing, candidate: undefined, next: undefined, current: navigation }})); 273 | 274 | // binding events to close promise 275 | on(NAVIGATION_IGNORED_EVENT, (s, e) => dispatch(POST_NAVIGATE_EVENT, e)); 276 | on(NAVIGATION_FAILED_EVENT, (s, e) => dispatch(POST_NAVIGATE_EVENT, e)); 277 | 278 | // registration 279 | on(REGISTER_EVENT, ({routing}, h) => 280 | ({ routing: { ...routing, handles: [h, ...routing.handles] }})); 281 | on(UNREGISTER_EVENT, ({routing}, {id}) => 282 | ({ routing: { ...routing, handles: routing.handles.filter(i => i.id !== id) }})); 283 | 284 | // public 285 | on(CANCEL_EVENT, ({routing}) => { 286 | /* istanbul ignore else */ 287 | if (routing.next || routing.candidate) { 288 | dispatch(NAVIGATION_CANCELLED_EVENT) 289 | } 290 | }); 291 | }; 292 | 293 | /** 294 | * Register the route handler to top of stack of handles. 295 | * 296 | * @example simple 297 | * onNavigate(store, '/home', () => console.log('going home')); 298 | * 299 | * @example redirection 300 | * onNavigate(store, '', () => navigate(store, '/404')); 301 | * 302 | * @example lazy loading 303 | * // admin page - lazy loading of modul'/admin', async (navigation, abortSignal) => { 304 | * // preload module 305 | * const adminModule = await import('/modules/adminModule.js'); 306 | * // if not aborted 307 | * if (!abortSignal.aborted) { 308 | * // unregister app level route handle 309 | * unRegister(); 310 | * // init module, which will register own handle for same route 311 | * adminModule.adminModule(store); 312 | * // navigate once again (with force flag) 313 | * navigate(store, navigation.url, false, true); 314 | * } 315 | * }); 316 | */ 317 | export const onNavigate = ( 318 | store: StoreonStore, 319 | route: string, 320 | callback: RouteCallback): () => void => { 321 | const id = handleId++; 322 | routes[id] = { 323 | id, callback, route, regexp: new RegExp(route), 324 | }; 325 | const r = { id, route }; 326 | store.dispatch(REGISTER_EVENT, r); 327 | return () => { 328 | delete routes[id]; 329 | store.dispatch(UNREGISTER_EVENT, r); 330 | }; 331 | }; 332 | 333 | /** 334 | * Navigate to provided route. 335 | */ 336 | export const navigate = ( 337 | store: StoreonStore, 338 | url: string, 339 | force?: boolean, 340 | options?: any): Promise => { 341 | const id = navId++; 342 | return new Promise((res, rej) => { 343 | const u = store.on(POST_NAVIGATE_EVENT, (s, { navigation, error }) => { 344 | if (id === navigation.id) { 345 | u(); 346 | error ? rej(error) : res(); 347 | } 348 | }); 349 | store.dispatch(PRE_NAVIGATE_EVENT, { navigation: { id, url, force, options } }); 350 | }); 351 | }; 352 | 353 | /** 354 | * Cancel ongoing navigation. 355 | */ 356 | export const cancelNavigation = (store: StoreonStore) => { 357 | store.dispatch(CANCEL_EVENT); 358 | }; 359 | 360 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storeon-async-router", 3 | "version": "2.0.0", 4 | "description": "Asynchronous router for [Storeon]", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/majo44/storeon-async-router.git" 8 | }, 9 | "bugs": "https://github.com/majo44/storeon-async-router/issues", 10 | "keywords": [ 11 | "storeon", 12 | "router", 13 | "navigation", 14 | "routing", 15 | "hook" 16 | ], 17 | "module": "./module/index.js", 18 | "main": "index.js", 19 | "types": "./index.d.ts", 20 | "author": { 21 | "email": "majo44@gmail.com", 22 | "name": "Pawel Majewski" 23 | }, 24 | "contributors": [ 25 | { 26 | "email": "majo44@gmail.com", 27 | "name": "Pawel Majewski" 28 | } 29 | ], 30 | "license": "MIT", 31 | "peerDependencies": { 32 | "storeon": "^3.1.5" 33 | }, 34 | "devDependencies": { 35 | "@size-limit/preset-small-lib": "^4.4.0", 36 | "@types/chai": "^4.2.11", 37 | "@types/chai-as-promised": "^7.1.2", 38 | "@types/mocha": "^7.0.2", 39 | "@types/node": "^13.9.1", 40 | "@types/node-fetch": "^2.5.5", 41 | "@types/sinon": "^7.5.2", 42 | "@types/sinon-chai": "^3.2.3", 43 | "@typescript-eslint/eslint-plugin": "^2.24.0", 44 | "@typescript-eslint/parser": "^2.24.0", 45 | "abortcontroller-polyfill": "^1.4.0", 46 | "chai": "^4.2.0", 47 | "chai-as-promised": "^7.1.1", 48 | "eslint": "^6.8.0", 49 | "husky": "^4.2.3", 50 | "mocha": "^7.1.1", 51 | "node-fetch": "^2.6.0", 52 | "npm-run-all": "^4.1.5", 53 | "nyc": "^14.1.1", 54 | "sinon": "^7.5.0", 55 | "sinon-chai": "^3.5.0", 56 | "size-limit": "^4.4.0", 57 | "source-map-support": "^0.5.16", 58 | "storeon": "^3.1.5", 59 | "ts-node": "^8.6.2", 60 | "tslib": "^1.11.1", 61 | "typedoc": "^0.17.1", 62 | "typescript": "^3.8.3" 63 | }, 64 | "scripts": { 65 | "size": "size-limit", 66 | "build": "run-s lint test compile:commonjs compile:module size docs ", 67 | "compile:module": "tsc --module esnext --outDir module", 68 | "compile:commonjs": "tsc", 69 | "docs": "typedoc --theme minimal --includeDeclarations --excludeExternals --out docs --exclude \"node_modules/**/*\" index.ts", 70 | "test": "nyc mocha *.spec.ts", 71 | "lint": "eslint \"index.ts\"", 72 | "format": "eslint \"index.ts\" --fix" 73 | }, 74 | "size-limit": [ 75 | { 76 | "limit": "1200 B", 77 | "path": "index.js" 78 | }, 79 | { 80 | "limit": "1200 B", 81 | "path": "module/index.js" 82 | } 83 | ], 84 | "mocha": { 85 | "require": [ 86 | "ts-node/register", 87 | "source-map-support/register" 88 | ], 89 | "fullTrace": true, 90 | "bail": true 91 | }, 92 | "nyc": { 93 | "extension": [ 94 | ".ts" 95 | ], 96 | "include": [ 97 | "index.ts" 98 | ], 99 | "reporter": [ 100 | "text-summary", 101 | "html", 102 | "lcov" 103 | ], 104 | "branches": 100, 105 | "lines": 100, 106 | "functions": 100, 107 | "statements": 100, 108 | "sourceMap": true, 109 | "instrument": true 110 | }, 111 | "eslintConfig": { 112 | "extends": [ 113 | "eslint:recommended", 114 | "plugin:@typescript-eslint/eslint-recommended", 115 | "plugin:@typescript-eslint/recommended" 116 | ], 117 | "rules": { 118 | "@typescript-eslint/no-explicit-any": 0, 119 | "@typescript-eslint/explicit-function-return-type": 0 120 | }, 121 | "parser": "@typescript-eslint/parser" 122 | }, 123 | "husky": { 124 | "hooks": { 125 | "pre-commit": "npm run build" 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true, 9 | "strictNullChecks": false 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | "module", 14 | "docs", 15 | "**/*.spec.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------