storeon-async-router


Asynchronous router for Storeon.
It size is ~1kB (minified and gzipped) and uses Size Limit to control size.
Overview
The key features are:
- allows async route handlers for prefetch the data or lazy loading of modules
- support for abort the routing if there was some navigation cancel eg. by fast clicking
- allows update routing definition in fly (eg, when you are loading some self module lazy which should add
self controlled routes).
- ignores same routes navigation
This router is implementation of idea of state first routing, which at first place reflects the
navigation within the state, and reflection within the UI stay on application side.
Also this library is decoupled from browser history.
Examples of integration with browser history or UI code you can find in recipes.
Install
npm i storeon-async-router --save
Requirements
Usage
import { createStoreon } from "storeon";
import { routingModule, onNavigate, navigate } from "storeon-async-router";
const store = createStoreon([routingModule]);
store.on("dataLoaded", (state, data) => ({ data }));
store.on("@changed", state => {
document.querySelector(".out").innerHTML = state.routing.next
? `Loading ${state.routing.next.url}`
: JSON.stringify(state.data);
});
onNavigate(store, "/home/(?<page>.*)", async (navigation, signal) => {
const homePageData = await fetch(`${navigation.params.page}.json`, {
signal
}).then(response => response.json());
store.dispatch("dataLoaded", homePageData);
});
document.querySelectorAll("a").forEach((anchor, no) =>
anchor.addEventListener("click", e => {
e.preventDefault();
navigate(store, anchor.getAttribute("href"));
})
);

Or visit working demo and try to run with Redux development tools, and
try to fast click with http throttling, to see the navigation cancellation.
Api
routingModule - is storeon module which contains the whole logic of routing
- this module contains reducer for the
routing state property which contains:
current current applied Navigation
next ongoing Navigation (if there is any)
onNavigate(store, route, callback) - function which registers route callback, on provided store
for provided route (path regexp string). Callback is a function which will be called if route will be matched,
Important think is that last registered handle have a higher
priority, so if at the end you will register multiple handle for same route,
only the last registered one will be used. onNavigate is returns function which can be used for
unregister the handle. Params:
store instance of store
route the route path regexp string, please notice that only path is matched and can contains the rote params,
If you want to read search params you have to do that in callback by parsing url string delivered there in
navigation object. On modern browsers you can use regexp group namings for path params.
callback the callback which will be called when provided route will be matched with requested url.
callback can returns undefined or promise. In case of promise, route will be not applied (navigation will be not
ended) until the promise will be not resolve. Callback is called with two parameters:
navigation ongoing Navigation object
signal which is AbortSignal,
to be notified that current processed navigation was cancelled. That parameter can be used directly on
calls of fetch api.
navigate(store, url, [force], [options]) - function which triggers navigation to particular url. Params:
store instance of store
url requested url string
force optional force navigation, if there is a registered route which will match the requested url, even for same url
as current the route callback will be called
options optional additional navigation options which will be delivered to route callback
for browser url navigation it can be eg. replace - for replacing url in the url bar, ect.
cancelNavigation(store) - function which cancel current navigation (if there is any in progress). Params:
Navigation object contains
url requested url string
id unique identifier of navigation
options additional options for navigation, for browser url navigation it can be
eg. replace - for replacing url in the url bar, ect..
force force the navigation, for the cases when even for same url as current have to be handled
params map of route parameters values (handled by matched route regexp grops)
route the route which handled that navigation
Recipes
Redirection
Redirection of navigation from one route handler to another route.
onNavigate(store, "/home/1", () => navigate(store, '/home/2'));

"Otherwise" Redirection
The very special case is "otherwise" route, such route is covers all uncovered routes and handler of such route
should simply redirect navigation to well known route.
Please remember also that "otherwise" route should be registered as a very first, as in [storeon-async-router] the
highest priority has last registered routes.
onNavigate(store, "", () => navigate(store, '/404'));
Async route handle
Preloading the data
For case when before of navigation we want to preload some data, we can use async route handle and postpone the navigation.
We can use abort signal for aborting the ongoing fetch.
onNavigate(store, "/home/(?<page>.*)", async (navigation, signal) => {
const homePageData = await fetch(`${navigation.params.page}.json`, {
signal
}).then(response => response.json());
store.dispatch("dataLoaded", homePageData);
});

Please notice that used in example RegExp named capture groups
(like /home/(?<page>.*)) are part of ES2018 standard, and this syntax is not supported yet on
all browsers. As a alternative you
can refer the parameters by the order no, so instead of navigation.params.page you can use navigation.params[0].
Lazy loading of submodule
For application code splitting we can simple use es6 import() function. In case when you will want to spilt your by the
routes, you can simple do that with async router. What you need to do is just await for import() your lazy module within the
route handle. You can additionally extend your routing within the loaded module.
const unRegister = onNavigate(
store,
"/admin",
async (navigation, abortSignal) => {
const adminModule = await import("./adminModule.js");
if (!abortSignal.aborted) {
unRegister();
adminModule.adminModule(store);
navigate(store, navigation.url, true);
}
}
);
export function adminModule(store) {
onNavigate(store, "/admin", async (navigation, signal) => {
const adminPageData = await fetch(`admin.json`, {
signal
}).then(response => response.json());
store.dispatch("dataLoaded", adminPageData);
});
}

Integration with browser history
In order to synchronize the routing state within the store with the browser history (back/forward, location)
we can simple connect the store with browser history object by fallowing code:
function getLocationFullUrl() {
return (
window.location.pathname +
(window.location.search ? window.location.search : "") +
(window.location.hash ? window.location.hash : "")
);
}
setTimeout(() => {
navigate(store, getLocationFullUrl(), false, { replace: true });
});
window.addEventListener("popstate", () => {
navigate(store, getLocationFullUrl());
});
store.on(NAVIGATE_ENDED_EVENT, async (state, navigation) => {
if (getLocationFullUrl() !== navigation.url) {
navigation.options && navigation.options.replace
? window.history.replaceState({}, "", navigation.url)
: window.history.pushState({}, "", navigation.url);
}
});

Please remember that with such solution you should probably also set in your html document head <base href="/"/>
Handling the anchor click events globally
To handle any html anchor click over the page you cansimple create global click handler like that:
document.body.addEventListener("click", function(event) {
if (
!event.defaultPrevented &&
event.target.tagName === "A" &&
event.target.href.indexOf(window.location.origin) === 0 &&
event.target.target !== "_blank" &&
event.button === 0 &&
event.which === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
const path = event.target.href.slice(window.location.origin.length);
navigate(store, path);
}
});

Encapsulate routing to shared router object
If you do not want always to deliver store to utility functions you can simple encapsulate all functionality to single
router object.
import createStore from 'storeon';
import { asyncRoutingModule, onNavigate, navigate, cancelNavigation } from 'storeon-async-router';
const store = createStore([asyncRoutingModule]);
function routerFactory(store) {
return {
get current() {
return store.get().routing.current;
},
navigate: navigate.bind(null, store),
onNavigate: onNavigate.bind(null, store)
}
}
const router = routerFactory(store);
router.onNavigate('/home', () => {});
router.navigate('/home');

Internal data flow
-
user registers the handles by usage of onNavigate (can do this in stereon module, but within the @init callback),
1.1 for each registered handle we generating unique id,
1.2 cache the handle under that id, and dispatch route register event with provided route and handle id
-
on route register we are storing in state provided route and id (at the top of stack)
-
on navigate event
3.1. we checking exit conditions (same route, or same route navigation in progres),
3.2. if there is any ongoing navigation we are dispatch navigation cancel event
3.3. then we are setting the next navigation in state,
3.4. asynchronously dispatch before navigation event
-
on before navigation event
4.1 we are looking in state for handle id which route matches requested url, by the matched id we are taking the
handle from cache,
4.2. we creates AbortController from which we are taking the AbortSignal,
4.3. we attach to storeon handle for navigation canceled event to call cancell on AbortController
4.4. we call handle with details of navigation and abortSignal, if the result of handle call is Promise, we are waits to
resolve,
4.5 we are dispatch navigation end event, and unregister navigation canceled handle
-
on navigation canceled we are clear the next navigation in state
-
on navigation end we move next to current ins state