npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ui5-lib-guard-router

v1.0.1

Published

UI5 Router extension with async navigation guards

Readme

ui5-lib-guard-router

Drop-in replacement for sap.m.routing.Router that intercepts navigation before route matching, target loading, or view creation — preventing flashes of unauthorized content and polluted browser history.

Born from SAP/openui5#3411, an open request since 2021 for native navigation guard support in UI5.

Related resources:

[!WARNING] This library is experimental. It is not battle-tested in production environments, and the API may change without notice. If you choose to consume it, you do so at your own risk — make sure to pin your version and review changes before upgrading.

Why

UI5's router has no way to block or redirect navigation before views render. The usual workaround — scattering guard logic across attachPatternMatched callbacks — causes flashes of unauthorized content, polluted browser history, and scattered guard logic across controllers.

This library solves all three by intercepting at the router level, before any route matching begins.

Install

npm install ui5-lib-guard-router

Setup

1. Add the library dependency and set the router class in your manifest.json:

{
	"sap.ui5": {
		"dependencies": {
			"libs": {
				"ui5.guard.router": {}
			}
		},
		"routing": {
			"config": {
				"routerClass": "ui5.guard.router.Router"
			}
		}
	}
}

All existing routes, targets, and navigation calls continue to work unchanged.

2. Register guards in your Component:

import UIComponent from "sap/ui/core/UIComponent";
import type { GuardRouter } from "ui5/guard/router/types";

export default class Component extends UIComponent {
	static metadata = {
		manifest: "json",
		interfaces: ["sap.ui.core.IAsyncContentCreation"],
	};

	init(): void {
		super.init();
		const router = this.getRouter() as unknown as GuardRouter;

		// Route-specific guard — redirect when not logged in
		router.addRouteGuard("protected", (context) => {
			return isLoggedIn() ? true : "home";
		});

		// Global guard — runs for every navigation
		router.addGuard((context) => {
			if (context.toRoute === "admin" && !isAdmin()) {
				return "home";
			}
			return true;
		});

		router.initialize();
	}
}

How it works

The library extends sap.m.routing.Router and overrides parse(), the single method through which all navigation flows (programmatic navTo, browser back/forward, direct URL changes). Guards run before any route matching, target loading, or view creation.

Because it extends the mobile router directly, all existing sap.m.routing.Router behavior (Targets, route events, navTo, back navigation) works unchanged.

The guard pipeline stays synchronous when all guards return plain values and only becomes async when a guard returns a Promise. A generation counter discards stale async results when navigations overlap, and an AbortSignal is passed to each guard so async work (like fetch) can be cancelled early.

API

All methods return this for chaining.

Guard registration

| Method | Description | | ---------------------------------------------------------- | ---------------------------------------------- | | addGuard(fn) | Global enter guard (runs for every navigation) | | addRouteGuard(routeName, fn) | Enter guard for a specific route | | addRouteGuard(routeName, { beforeEnter?, beforeLeave? }) | Enter and/or leave guards via object form | | addLeaveGuard(routeName, fn) | Leave guard (runs when leaving the route) |

Guard removal

| Method | Description | | ------------------------------------------------------------- | ------------------------------------------------ | | removeGuard(fn) | Remove a global enter guard | | removeRouteGuard(routeName, fn) | Remove an enter guard | | removeRouteGuard(routeName, { beforeEnter?, beforeLeave? }) | Remove enter and/or leave guards via object form | | removeLeaveGuard(routeName, fn) | Remove a leave guard |

GuardContext

Every guard receives a GuardContext object:

| Property | Type | Description | | ------------- | -------------------------------------------------- | --------------------------------------------------- | | toRoute | string | Target route name (empty if no match) | | toHash | string | Raw hash being navigated to | | toArguments | Record<string, string \| Record<string, string>> | Parsed route parameters | | fromRoute | string | Current route name (empty on first navigation) | | fromHash | string | Current hash | | signal | AbortSignal | Aborted when a newer navigation supersedes this one |

Return values

Enter guards (addGuard, addRouteGuard):

| Return | Effect | | ---------------------------------------------- | ----------------------------------------------- | | true | Allow navigation | | false | Block (stay on current route, no history entry) | | "routeName" | Redirect to named route (replaces history) | | { route, parameters?, componentTargetInfo? } | Redirect with route parameters | | anything else (null, undefined) | Treated as block |

Only strict true allows navigation — no truthy coercion.

Leave guards (addLeaveGuard):

| Return | Effect | | --------------------------------- | ------------------------------- | | true | Allow leaving the current route | | false (or any non-true value) | Block |

Leave guards cannot redirect. For redirection logic, use enter guards on the target route.

Execution order

  1. Leave guards for the current route (registration order)
  2. Global enter guards (registration order)
  3. Route-specific enter guards for the target (registration order)
  4. Pipeline short-circuits at the first non-true result

Examples

Async guard with AbortSignal

router.addRouteGuard("dashboard", async (context) => {
	const res = await fetch(`/api/access/${context.toRoute}`, {
		signal: context.signal,
	});
	const { allowed } = await res.json();
	return allowed ? true : "forbidden";
});

Redirect with parameters

router.addGuard((context) => {
	if (context.toRoute === "old-detail") {
		return {
			route: "detail",
			parameters: { id: context.toArguments.id },
		};
	}
	return true;
});

Guard factories

// guards.ts
import JSONModel from "sap/ui/model/json/JSONModel";
import type { GuardFn, LeaveGuardFn, GuardContext, GuardResult } from "ui5/guard/router/types";

export function createAuthGuard(authModel: JSONModel): GuardFn {
	return (context: GuardContext): GuardResult => {
		return authModel.getProperty("/isLoggedIn") ? true : "home";
	};
}

export function createDirtyFormGuard(formModel: JSONModel): LeaveGuardFn {
	return (context: GuardContext): boolean => {
		return !formModel.getProperty("/isDirty");
	};
}

Object form (enter + leave)

router.addRouteGuard("editOrder", {
	beforeEnter: createAuthGuard(authModel),
	beforeLeave: createDirtyFormGuard(formModel),
});

Dynamic guard registration

Guards can be added or removed at any point during the router's lifetime:

const logGuard: GuardFn = (ctx) => {
	console.log(`Navigation: ${ctx.fromRoute} → ${ctx.toRoute}`);
	return true;
};

router.addGuard(logGuard);
// later...
router.removeGuard(logGuard);

Leave guard with controller lifecycle

import type { GuardRouter, LeaveGuardFn } from "ui5/guard/router/types";
import { createDirtyFormGuard } from "./guards";

export default class EditOrderController extends Controller {
	private _leaveGuard: LeaveGuardFn;

	onInit(): void {
		const formModel = new JSONModel({ isDirty: false });
		this.getView()!.setModel(formModel, "form");

		const router = (this.getOwnerComponent() as UIComponent).getRouter() as unknown as GuardRouter;
		this._leaveGuard = createDirtyFormGuard(formModel);
		router.addLeaveGuard("editOrder", this._leaveGuard);
	}

	onExit(): void {
		const router = (this.getOwnerComponent() as UIComponent).getRouter() as unknown as GuardRouter;
		router.removeLeaveGuard("editOrder", this._leaveGuard);
	}
}

[!TIP] User feedback on blocked navigation: When a leave guard blocks, the router silently restores the previous hash. There is no built-in confirmation dialog. Show a sap.m.MessageBox.confirm() inside your leave guard (returning the user's choice as a Promise<boolean>) to make the block visible.

[!NOTE] Guard cleanup and lifecycle

Component level: The router's destroy() method automatically clears all registered guards when the component is destroyed (including during FLP navigation).

Controller level: UI5's routing caches views indefinitely, so onExit is called only when the component is destroyed, not on every navigation away. Controller-registered guards therefore persist across in-app navigations. This is typically the desired behavior for route-specific guards tied to view state.

In FLP apps with sap-keep-alive enabled, the component persists when navigating to other apps. Guards remain registered since the same instance is reused.

Native alternative for leave guards: Fiori Launchpad data loss prevention

If your app runs inside SAP Fiori Launchpad (FLP), the shell provides built-in data loss protection through two public APIs on sap.ushell.Container:

setDirtyFlag(bDirty) (since 1.27.0): A simple boolean flag. When set to true, FLP shows a browser confirm() dialog when the user attempts cross-app navigation (home button, other tiles), browser back/forward out of the app, or page refresh/close:

sap.ushell.Container.setDirtyFlag(true); // mark unsaved changes
sap.ushell.Container.setDirtyFlag(false); // clear after save

registerDirtyStateProvider(fn) (since 1.31.0): Registers a callback that FLP calls during navigation to dynamically determine dirty state. The callback receives a NavigationContext with isCrossAppNavigation (boolean) and innerAppRoute (string), allowing the provider to distinguish between cross-app and in-app navigation:

const dirtyProvider = (navigationContext) => {
	if (navigationContext?.isCrossAppNavigation) {
		return formModel.getProperty("/isDirty");
	}
	return false; // let in-app routing handle it
};
sap.ushell.Container.registerDirtyStateProvider(dirtyProvider);

// Clean up (since 1.67.0)
sap.ushell.Container.deregisterDirtyStateProvider(dirtyProvider);

Note: getDirtyFlag() is deprecated since UI5 1.120. FLP internally uses getDirtyFlagsAsync() (private) which combines the flag with all registered providers. The synchronous getDirtyFlag() still works but should not be relied upon in new code.

How the two approaches complement each other: FLP's data loss protection operates at the shell navigation filter level, intercepting navigation before the hash change reaches your app's router. Leave guards operate inside your app's router, intercepting route-to-route navigation. For complete coverage:

  • Use leave guards for in-app route changes (e.g., navigating from an edit form to a list within your app)
  • Use setDirtyFlag or registerDirtyStateProvider for FLP-level navigation (cross-app, browser close, home button)

See the FLP Dirty State Research for a detailed analysis of the FLP internals.

Limitations

Redirect targets bypass guards

When a guard redirects navigation from route A to route B, route B's guards are not evaluated. The redirect commits immediately.

This matters when the redirect target has its own guards. For example:

User navigates to "dashboard"
  → dashboard guard checks permissions, returns "profile"
  → profile guard checks onboarding status ← this guard is SKIPPED
  → profile view renders

This is intentional. Evaluating guards on redirect targets introduces the risk of infinite loops (A → B → A → B → ...). While solvable with a visited-set that detects cycles, the implementation adds significant complexity. This is particularly true when redirect targets have async guards, since the redirect chain can no longer be bracketed in a single synchronous call stack. The chain state must then persist across async boundaries and be cleared only by terminal events (commit, block, or loop detection).

In practice, redirect targets are typically "safe" routes like home or login that don't have guards of their own. If you need guard logic on a redirect target, run the check inline before returning the redirect:

router.addRouteGuard("dashboard", (context) => {
	if (!hasPermission()) {
		return isOnboarded() ? "profile" : "onboarding";
	}
	return true;
});

URL bar shows target hash during async guards

When a guard returns a Promise (e.g., a fetch call to check permissions), the browser's URL bar shows the target hash while the guard is resolving. If the guard ultimately blocks or redirects, the URL reverts. However, there is a brief window where the displayed URL doesn't match the active route.

This does not affect sync guards, which resolve in the same tick as the hash change (the URL flicker is imperceptible).

Why the router doesn't handle this: UI5's HashChanger updates the URL and fires hashChanged before parse() is called. The router cannot prevent the URL change; it can only react to it. Frameworks like Vue Router and Angular Router avoid this by controlling the URL update themselves (calling history.pushState only after guards resolve), but UI5's architecture doesn't allow this without intercepting at the HashChanger level, which is globally scoped and fragile.

User clicks link / navTo()
        ↓
HashChanger updates browser URL    ← URL changes HERE
        ↓
HashChanger fires hashChanged
        ↓
Router.parse() called              ← guards run HERE
        ↓
   ┌────┴────┐
allowed    blocked
   ↓          ↓
views      _restoreHash()
load       reverts URL

Show a busy indicator while async guards resolve. This communicates to the user that navigation is in progress, making the URL bar state a non-issue:

router.addRouteGuard("dashboard", async (context) => {
	const app = rootView.byId("app") as App;
	app.setBusy(true);
	try {
		const res = await fetch(`/api/access/${context.toRoute}`, {
			signal: context.signal,
		});
		const { allowed } = await res.json();
		return allowed ? true : "home";
	} finally {
		app.setBusy(false);
	}
});

This follows the same pattern as TanStack Router's pendingComponent: the URL reflects the intent while a loading state signals that the navigation hasn't committed yet.

Compatibility

[!IMPORTANT] Minimum UI5 version: 1.118

The library uses sap.ui.core.Lib for library initialization, which was introduced in UI5 1.118. The Router itself only depends on APIs available since 1.75 (notably getRouteInfoByHash), but the library packaging sets the effective floor. Developed and tested against OpenUI5 1.144.0.

License

MIT