navigation lifecycle - greydragon888/real-router GitHub Wiki
When you call router.navigate(), the router doesn't just swap a URL. It runs a structured pipeline: validating arguments, calculating which route segments need to change, running guards in a specific order, updating state, and notifying subscribers. Understanding this pipeline helps you predict when guards run, why some navigations get cancelled, and how errors surface. For the full parameter reference, see navigate.
Here's what happens from the moment navigate() is called to the moment the promise resolves.
flowchart TD
A([navigate called]) --> B{Router started?}
B -- No --> ERR1([Reject: ROUTER_NOT_STARTED])
B -- Yes --> C{Route exists?}
C -- No --> ERR2([Reject: ROUTE_NOT_FOUND])
C -- Yes --> D{forwardTo redirect?}
D -- Yes --> E[Resolve target route]
E --> F{Same state?}
D -- No --> F
F -- Yes, no reload --> ERR3([Reject: SAME_STATES])
F -- No / reload --> G[FSM: READY → TRANSITION_STARTED]
G --> H[Emit TRANSITION_START\nNotify plugins: onTransitionStart]
H --> I[Calculate transition path\ntoDeactivate / toActivate / intersection]
I --> J{Has segments\nto deactivate?}
J -- Yes --> K[Run deactivation guards\nreverse order: deepest first]
J -- No --> LA[FSM: TRANSITION_STARTED → LEAVE_APPROVED\nEmit TRANSITION_LEAVE_APPROVE\nNotify plugins: onTransitionLeaveApprove\nFire subscribeLeave callbacks]
K --> KCHECK{Guard passed?}
KCHECK -- No --> ERR4([Reject: CANNOT_DEACTIVATE\nEmit TRANSITION_ERROR\nonTransitionError])
KCHECK -- Cancelled? --> ERR5([Reject: TRANSITION_CANCELLED\nEmit TRANSITION_CANCEL\nonTransitionCancel])
KCHECK -- Yes --> LA
LA --> L{Has segments\nto activate?}
L -- Yes --> M[Run activation guards\nforward order: shallowest first]
L -- No --> N[Update state\nObject.freeze]
M --> MCHECK{Guard passed?}
MCHECK -- No --> ERR6([Reject: CANNOT_ACTIVATE\nEmit TRANSITION_ERROR\nonTransitionError])
MCHECK -- Cancelled? --> ERR5
MCHECK -- Yes --> N
N --> O[FSM: LEAVE_APPROVED → READY]
O --> P[Emit TRANSITION_SUCCESS\nNotify plugins: onTransitionSuccess\nNotify subscribers]
P --> Q([Promise resolves with new State])
1. Argument validation. The router checks that the route name is a string and that the options object is valid. Invalid arguments throw a TypeError immediately, before any async work begins.
2. Route lookup. The router checks whether the named route exists. If not, the promise rejects with ROUTE_NOT_FOUND.
3. ForwardTo resolution. If the target route has a forwardTo configuration, the router resolves the actual destination before doing anything else. Guards on the source route are skipped entirely. See ForwardTo Redirects below.
4. Same-state check. If the resolved target state equals the current state (same name and params), the router rejects with SAME_STATES unless you pass reload: true or force: true.
5. FSM transition. The router's internal state machine moves from READY to TRANSITION_STARTED. This is what prevents two navigations from running simultaneously.
6. TRANSITION_START event. The $$start event fires and plugins receive onTransitionStart(toState, fromState). At this point, the transition is committed to proceeding.
7. Transition path calculation. The router computes which segments to deactivate and which to activate. See Transition Path below.
8. Deactivation guards. Guards run for each segment being deactivated, starting from the deepest (most specific) segment and working up toward the intersection. If any guard returns a falsy value, navigation stops with CANNOT_DEACTIVATE.
8a. LEAVE_APPROVE phase. Once all deactivation guards pass, the FSM moves from TRANSITION_STARTED to LEAVE_APPROVED. The TRANSITION_LEAVE_APPROVE event fires, plugins receive onTransitionLeaveApprove(toState, fromState), and all subscribeLeave() callbacks run. Listeners receive { route, nextRoute, signal: AbortSignal } and may return Promise<void> to block the pipeline (e.g., exit animations). All listeners run in parallel via Promise.allSettled — one listener's failure does not prevent others from executing. At this point the current route has not yet changed — router.getState() still returns the old state — but departure is confirmed. Activation guards run only after all leave listeners complete.
9. Activation guards. Guards run for each segment being activated, starting from the shallowest (closest to root) and working down. If any guard returns a falsy value, navigation stops with CANNOT_ACTIVATE.
10. State update. The new state is frozen with Object.freeze and stored as the current state. The previous state is preserved for subscribers.
11. FSM transition back. The state machine moves from LEAVE_APPROVED to READY.
12. Success notification. The $$success event fires, plugins receive onTransitionSuccess(toState, fromState, opts), and all subscribers are called. The promise resolves with the new state.
navigateToNotFound() bypasses the entire transition pipeline shown above. It does NOT emit TRANSITION_START, does NOT execute guards, and does NOT perform FSM transition. Only TRANSITION_SUCCESS is emitted directly. This is intentional — when application logic determines a resource doesn't exist, deactivation guards (e.g., "unsaved changes?") are meaningless.
See: navigateToNotFound
Before any guard runs, the router needs to know which segments are actually changing. It does this by comparing the current state's route hierarchy with the target state's route hierarchy, finding where they diverge.
Every route name in dot notation maps to a hierarchy of segments. users.profile means the segments users and users.profile. The router compares these segment lists from the root down until it finds the first difference. That point of divergence is the intersection — the deepest common ancestor that doesn't need to change.
Everything below the intersection in the current state goes into toDeactivate. Everything below the intersection in the target state goes into toActivate. The deactivation list is built in reverse order (deepest first). The activation list is in forward order (shallowest first).
flowchart LR
subgraph "Example 1: users.profile → admin.settings"
direction TB
ROOT1[root\nintersection: ''] --> U1[users\ndeactivate]
U1 --> UP1[users.profile\ndeactivate first]
ROOT1 --> A1[admin\nactivate first]
A1 --> AS1[admin.settings\nactivate]
end
subgraph "Example 2: users.list → users.profile"
direction TB
ROOT2[root] --> U2[users\nintersection: 'users']
U2 --> UL2[users.list\ndeactivate]
U2 --> UPR2[users.profile\nactivate]
end
Example 1: users.profile to admin.settings
These routes share no common ancestor below the root. The intersection is '' (empty string, representing the root). The transition path is:
-
toDeactivate:['users.profile', 'users']— profile deactivates first, then users -
toActivate:['admin', 'admin.settings']— admin activates first, then settings -
intersection:''
Every segment changes. The user's profile component unmounts before the admin shell mounts.
Example 2: users.list to users.profile
These routes share the users segment. The intersection is 'users'. Only the leaf segments change:
-
toDeactivate:['users.list'] -
toActivate:['users.profile'] -
intersection:'users'
The users segment stays mounted. Only the child view swaps out.
The ordering isn't arbitrary. Deactivation guards run first because the current state has the most context about whether it's safe to leave. A form component knows if there are unsaved changes. A video player knows if something is buffering. The most specific component (deepest segment) gets to veto the navigation before its parent does, because the parent can't know what the child is doing.
Activation runs after deactivation for the same reason: there's no point checking whether you can enter a new route if you can't leave the current one. And within activation, the shallowest segment runs first because parent routes often establish context (authentication, layout, data) that child routes depend on. If the parent guard blocks, the child never needs to run.
Navigation can fail at several points. Each failure produces a specific error code and triggers the appropriate events. For the full error reference, see error-codes.
| When it fails | Error code | Events emitted |
|---|---|---|
| Router not started | ROUTER_NOT_STARTED |
None |
| Route doesn't exist | ROUTE_NOT_FOUND |
TRANSITION_ERROR, onTransitionError
|
| Same state, no reload | SAME_STATES |
TRANSITION_ERROR, onTransitionError
|
| Deactivation guard blocked | CANNOT_DEACTIVATE |
TRANSITION_ERROR, onTransitionError
|
| Activation guard blocked | CANNOT_ACTIVATE |
TRANSITION_ERROR, onTransitionError
|
| Navigation cancelled | TRANSITION_CANCELLED |
TRANSITION_CANCEL, onTransitionCancel
|
| Guard threw an error | TRANSITION_ERR |
TRANSITION_ERROR, onTransitionError
|
ROUTER_NOT_STARTED is special: it rejects the promise but emits no events, because the event system isn't running yet.
CANNOT_DEACTIVATE and CANNOT_ACTIVATE both carry a segment field on the error, telling you which route segment's guard blocked the navigation. This is useful for debugging when you have guards on multiple segments.
TRANSITION_ERR wraps unexpected errors thrown inside guards. The original error is available via error.getField('originalError').
When a guard blocks navigation, the router emits TRANSITION_ERROR and calls onTransitionError(toState, fromState, err) on all plugins. Note that toState can be undefined in some edge cases (for example, when the route doesn't exist and no target state could be constructed).
// Handling guard failures
router.navigate("admin").catch((err) => {
if (err.code === "CANNOT_ACTIVATE") {
// Which segment blocked it?
const blockedSegment = err.getField("segment");
router.navigate("login");
}
});A navigation can be cancelled at any point during the guard phase. Cancellation produces TRANSITION_CANCELLED, emits TRANSITION_CANCEL, and calls onTransitionCancel(toState, fromState) on plugins.
Starting a new navigation while one is in progress cancels the previous one. The router aborts the in-flight transition's internal signal, which causes any awaited guard to stop. The first promise rejects with TRANSITION_CANCELLED. The second navigation proceeds normally.
const p1 = router.navigate("users");
const p2 = router.navigate("admin");
// p1 rejects with TRANSITION_CANCELLED
// p2 resolves normallyThis is the most common form of cancellation and is usually intentional. You don't need to handle it unless you want to distinguish it from other errors.
Calling router.stop() while a navigation is in progress aborts the transition immediately. The in-flight promise rejects with TRANSITION_CANCELLED. The router returns to a stopped state and can be restarted.
You can pass an external AbortSignal via opts.signal. If the signal is aborted before or during the transition, navigation cancels with TRANSITION_CANCELLED.
const controller = new AbortController();
router.navigate("slow-route", {}, { signal: controller.signal });
// Cancel from outside
controller.abort();Guards receive the signal as an optional third parameter. This lets them cancel in-flight async work cooperatively:
lifecycle.addActivateGuard(
"dashboard",
() => async (toState, fromState, signal) => {
const res = await fetch("/api/check", { signal });
return res.ok;
},
);If fetch throws an AbortError because the signal fired, the router automatically converts it to TRANSITION_CANCELLED. You don't need to catch it in the guard.
If you pass a signal that's already aborted, the navigation rejects immediately without starting a transition at all.
State change is atomic — router.getState() updates in one step inside completeTransition. A navigation either fully commits or fully rolls back. If canDeactivate passes but a subsequent canActivate guard blocks, the router stays on the original route as if nothing happened.
canDeactivate ✓ → canActivate ✗ → nothing changed, still on the original route
canDeactivate ✓ → canActivate ✓ → state updated, route was actually left
However, the transition pipeline now has a named intermediate phase: after all canDeactivate guards pass, the FSM enters LEAVE_APPROVED. At this point the current route has not yet changed, but departure is confirmed.
This is the safe window for side-effects: router.subscribeLeave() callbacks fire here, giving code a chance to save scroll positions, abort fetch requests, and track analytics — with the guarantee that these effects only run when the user has genuinely left.
LEAVE_APPROVED sits between deactivation and activation in the pipeline:
canDeactivate ✓ → FSM: LEAVE_APPROVED → subscribeLeave() fires (sync or async) → canActivate runs
router.getState() still returns the current route during this phase. The state hasn't changed yet. But router.isLeaveApproved() returns true, and any subscribeLeave() listener can safely read the current DOM, scroll position, or form state before the new route activates.
Leave listeners that return Promise<void> block the pipeline — activation guards wait until all Promises settle. This enables exit animations and View Transitions to complete before the new route mounts. Listeners receive AbortSignal for cooperative cancellation on concurrent navigation.
If a canActivate guard then blocks the navigation, the side-effects in subscribeLeave() already ran — but that's fine. Saving a scroll position or aborting a fetch is idempotent. The user stays on the same page, and the saved data is simply not used.
router.subscribeLeave() and the onTransitionLeaveApprove plugin hook are the correct tools for per-route departure logic. Both fire after deactivation is confirmed, before activation begins:
router.subscribeLeave(({ route, nextRoute }) => {
if (route.name === "users.profile") {
analytics.track("left_profile", { userId: route.params.id });
}
});For enter logic, onTransitionSuccess remains the right place — toState.transition.segments tells you exactly which segments were activated. See Plugin Architecture — Per-Route Reactions and subscribeLeave() for full documentation.
A route can declare a forwardTo property in its configuration. When the router resolves a navigation to that route, it transparently redirects to the target route instead.
const routes = [
{ name: "settings", path: "/settings", forwardTo: "settings.general" },
{ name: "settings.general", path: "/settings/general" },
{ name: "settings.security", path: "/settings/security" },
];Navigating to settings automatically navigates to settings.general. The user never lands on settings itself.
ForwardTo runs before guards. The redirect is resolved during argument validation, before the transition path is calculated and before any guard runs. Guards on the source route (settings in the example) are skipped entirely. Guards on the destination route (settings.general) run normally.
This makes forwardTo suitable for default child routes and index redirects. It's not a guard-based redirect — it's a route configuration that says "this route is an alias for another."
forwardTo can also be a function that receives dependencies and params, allowing dynamic redirect targets:
{
name: "dashboard",
path: "/dashboard",
forwardTo: (getDependency, params) => {
const auth = getDependency("auth");
return auth.isAdmin() ? "dashboard.admin" : "dashboard.user";
}
}For full forwardTo configuration details, see Route.
- navigate — full parameter and option reference
- canNavigateTo — synchronous guard check without navigating
- start — starting the router
- stop — stopping the router and cancelling in-flight navigation
- subscribe — subscribing to state changes
- addActivateGuard — registering activation guards
- addDeactivateGuard — registering deactivation guards
- error-codes — complete error code reference