Routing
Route Hooks and Guards
Route lifecycle hooks, application-level guards, middleware, restarting and shutting down the router.
A navigation goes through several steps between the address changing and the page coming on screen. You can step into these from three places: the route's own hooks (onEntering, onEnter, onLeave, onUpdate), application-level guards that run on every navigation (useGuard), and middleware that runs before the address is resolved (app.use).
Route lifecycle hooks
Hooks tied to the navigation lifecycle can be defined on a RouteItem:
| Hook | When |
|---|---|
onEntering(ctx) |
Before the route component is created (after the guards; the page is not in the DOM yet); on a parameter change on the same route, after onUpdate. |
onEnter(ctx) |
After the route component has been mounted and onShow has run. |
onLeave(ctx) |
When leaving the route (also on a parameter change on the same route), before the useGuard guards. It cancels the navigation by returning false or { cancel: true, reason }. |
onUpdate(ctx) |
Only when the parameters change on the same route; onEntering runs after it and the page is rebuilt with the new parameters. With keepAlive: true the same instance is kept and only the hooks run. |
The order: onLeave (the route being left) → useGuard → onUpdate (only on a parameter change) → onEntering → creating and placing the component → onShow → onEnter → onRouterChanged → disposing the leaving page after the new page has come on screen (see The page that leaves).
Context objects: onEntering/onEnter get { path, params, meta, to: { path, params, meta } }; onLeave gets { from: { path, params, meta }, to: { path, params, meta, redirectedFrom?, direction?, options?, state? } }; onUpdate gets { from: { path, params }, to: { path, params }, meta }. Hooks can be async; the router waits for the result.
An error thrown by these hooks (including a rejected promise in an async hook) and an error thrown by onShow (including the rejected promise of an async onShow) are reported with the code MJX306; the navigation goes on. If the route component cannot be created (the constructor throws, the import fails), MJX304 is reported and the fallbacks.error page is shown.
With useRouter({ hooks }) the same four hooks (onEntering, onEnter, onLeave, onUpdate) are defined application-wide; a global hook runs only for routes that have no hook of their own, and a route's own hook replaces the global one.
onLeave receives the context of the route being left; since it also runs on a parameter change on the same route, from.params is always the parameters of the address being left. The hook reaches services through useApplication().services. In the example, the note being left is deleted if it is empty:
import { useApplication, type RouteItem } from "@motifx/core";
import NotePage from "./pages/NotePage";
import { NotesService } from "./services/notes";
const note: RouteItem = {
path: "/{id(\\d+)}",
control: NotePage,
onLeave: ({ from }) => {
const notes = useApplication().services.get(NotesService);
const left = notes.find(from.params.id);
if (left && !left.title.trim() && !left.body.trim()) notes.remove(left.id);
},
};
import { useApplication } from "@motifx/core";
import NotePage from "./pages/NotePage";
import { NotesService } from "./services/notes";
const note = {
path: "/{id(\\d+)}",
control: NotePage,
onLeave: ({ from }) => {
const notes = useApplication().services.get(NotesService);
const left = notes.find(from.params.id);
if (left && !left.title.trim() && !left.body.trim()) notes.remove(left.id);
},
};
To stop the navigation, the hook returns false or { cancel: true, reason: "…" }; the result of app.navigate becomes { ok: false, cancelled: true, reason }.
Navigation guards and hooks (application level)
Global guards and hooks are defined on Application. useGuard runs before every navigation and gives its decision with next: next() continues, next("/path") redirects to another address, and next(false) cancels the navigation; if next is never called, the navigation is blocked. onRouterChanged runs after every navigation (startup and not-found addresses included); to tell startup apart, read the initial field in the payload.
A guard makes its decision by looking at the target's to.params and to.meta values; onRouterChanged runs with the values in the payload after the navigation is over. In the example the guard changes the address if the target note is archived, and onRouterChanged writes the tab title:
const notes = app.provider.get(NotesService);
app.useGuard(({ to }, next) => {
const note = to.params.id ? notes.find(to.params.id) : undefined;
if (note?.archived && to.meta.folder !== "archive") next(`/archive/${note.id}`);
else next();
});
app.onRouterChanged(({ meta }) => {
document.title = meta.folder ? `${FOLDERS[meta.folder as Folder].title} · Notes` : "Notes";
});
const notes = app.provider.get(NotesService);
app.useGuard(({ to }, next) => {
const note = to.params.id ? notes.find(to.params.id) : undefined;
if (note?.archived && to.meta.folder !== "archive") next(`/archive/${note.id}`);
else next();
});
app.onRouterChanged(({ meta }) => {
document.title = meta.folder ? `${FOLDERS[meta.folder].title} · Notes` : "Notes";
});
Guards run on every navigation, the startup navigation included, in the order they were added; the first guard that redirects or cancels ends the chain.
If the address bar shows the blocked path (first load, back/forward), a guard's next('/login') redirect replaces the history entry with replace, so the "back" button does not return to the blocked path and get stuck on the same guard again. In a programmatic navigation (from another page), the target is added to the history as usual.
If a guard throws (or the promise it returns is rejected), the navigation is cancelled and the error is reported with the code MJX306 (The guard hook threw.); the result of app.navigate becomes { ok: false, cancelled: true, reason: 'guard' }. In a back/forward navigation the address is rolled back, as with other cancellations.
If back/forward is cancelled, the address is rolled back. The browser's back/forward button (and the system back gesture on Android) changes the address before the navigation starts. If a guard, onLeave or middleware cancels this navigation, the router returns the address with history.go to the entry of the page on screen; the address and the screen do not drift apart, and the history is not damaged. Thanks to this, an "unsaved changes" confirmation works correctly on the back button too. For this, the router writes a namespaced order stamp (__motifHistory) into the history.state of every history entry; if some other code calls history.pushState directly, the order calculation can slip.
Middleware — app.use
To add a middle layer to the navigation resolution chain:
app.use(async (ctx, next) => {
// ctx: RouteResolveContext — { uri, context (Application), rewritePath(uri) }
if (ctx.uri === "/archived") ctx.rewritePath("/archive"); // change the target
await next(); // continue the chain
});
app.use(async (ctx, next) => {
// ctx: RouteResolveContext — { uri, context (Application), rewritePath(uri) }
if (ctx.uri === "/archived") ctx.rewritePath("/archive"); // change the target
await next(); // continue the chain
});
Middleware runs in the order it was added, before redirects (redirect), onLeave and the guards. Middleware that does not call next() cancels the navigation. The address given with rewritePath becomes the address navigated to and written to the address bar. Middleware does not run on the router's startup navigation (run() and restartRouter()); guards do.
Restarting and shutting down the router
app.restartRouter(uri?) sets up a new router and disposes the old one. If useRouter was called again after run(), the latest configuration is used, and otherwise the current one; a new configuration given after run() takes effect only through restartRouter(), and until then the running router carries on with the page on screen, the navigation and the route fields under the old table. The new router starts at the root (/); the address of the old table is not carried over to the new one. If it should start at another address, give the address: app.restartRouter("/notes/7"). If the address bar changes, the current history entry is overwritten, and no entry is added to the history; the entry's state value is kept if the address stays the same and cleared if it changes. In shell mode the address bar is not touched.
The route table is rebuilt, and the keepAlive cache and the pages kept in the stack are dropped; scrollMemory positions are kept. The page and layout on screen leave like the page that leaves: their bindings stop right away, their leave transitions play, the new page is built after that, and the cleanup work runs after the new page has come on screen. A layout given as a ready instance or as a singleton stays in place if it is in the same spot in the new table; onDeactivated / onActivated do not run. The route fields move to the new route in a single step after the leaving page has come off the screen; a layout that stays in place goes from the old route to the new one without seeing an empty value in between.
Guards run on the restart's startup navigation as well (from is null, to.direction is 'initial'), and at that moment app.router shows the old route. A redirect (next("/login")) is applied, and the address is again written over the current entry. A cancellation (next(false), a next that is never called, or a guard that throws) does not undo the restart, it only keeps the page from being shown: the old page leaves with its leave transition, then the route fields are emptied (uri '', ok false), the address bar does not change and onRouterChanged does not arrive; the next navigation works normally with the new table. onLeave does not run. onRouterChanged runs with initial: true and direction: 'initial'. The return value is the result of the startup navigation (see Navigating in code); if the restarted router was ended by a new restartRouter() or by app.dispose() before that navigation finished, reason is 'restart' or 'dispose'. Called before run(), it does nothing and returns undefined.
app.dispose() disposes the router too (listeners, the keepAlive cache, the pages in the stack) and rolls back the address only if the router set it: in history mode it makes it / with history.replaceState, in hash/file mode it removes only the # part (the document's path and query stay), and in shell mode or without a router it does not touch the address; no entry is added to the history. It returns a promise that resolves once the route pages, the RouterView, the shell and then the services have been disposed; the host element given to run() stays on the page, and the same host can be used with a new run().
Next step
For mobile-style back navigation, go on to Stack Navigation.