ui5-lib-guard-router 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +168 -53
  2. package/dist/.ui5/build-manifest.json +2 -2
  3. package/dist/index.d.ts +7 -3
  4. package/dist/resources/ui5/guard/router/.library +1 -1
  5. package/dist/resources/ui5/guard/router/NavigationOutcome-dbg.js +2 -0
  6. package/dist/resources/ui5/guard/router/NavigationOutcome-dbg.js.map +1 -1
  7. package/dist/resources/ui5/guard/router/NavigationOutcome.d.ts +2 -0
  8. package/dist/resources/ui5/guard/router/NavigationOutcome.d.ts.map +1 -1
  9. package/dist/resources/ui5/guard/router/NavigationOutcome.js +1 -1
  10. package/dist/resources/ui5/guard/router/NavigationOutcome.js.map +1 -1
  11. package/dist/resources/ui5/guard/router/Router-dbg.js +349 -87
  12. package/dist/resources/ui5/guard/router/Router-dbg.js.map +1 -1
  13. package/dist/resources/ui5/guard/router/Router.d.ts +147 -10
  14. package/dist/resources/ui5/guard/router/Router.d.ts.map +1 -1
  15. package/dist/resources/ui5/guard/router/Router.js +1 -1
  16. package/dist/resources/ui5/guard/router/Router.js.map +1 -1
  17. package/dist/resources/ui5/guard/router/library-dbg.js +1 -1
  18. package/dist/resources/ui5/guard/router/library-dbg.js.map +1 -1
  19. package/dist/resources/ui5/guard/router/library-preload.js +4 -4
  20. package/dist/resources/ui5/guard/router/library-preload.js.map +1 -1
  21. package/dist/resources/ui5/guard/router/library.js +1 -1
  22. package/dist/resources/ui5/guard/router/library.js.map +1 -1
  23. package/dist/resources/ui5/guard/router/manifest.json +1 -1
  24. package/dist/resources/ui5/guard/router/types-dbg.js.map +1 -1
  25. package/dist/resources/ui5/guard/router/types.d.ts +135 -8
  26. package/dist/resources/ui5/guard/router/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/NavigationOutcome.ts +6 -4
  29. package/src/Router.ts +457 -112
  30. package/src/manifest.json +1 -1
  31. package/src/types.ts +140 -8
  32. package/tsconfig.json +2 -3
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  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.
4
4
 
5
- > Born from [SAP/openui5#3411](https://github.com/SAP/openui5/issues/3411), an open request since 2021 for native navigation guard support in UI5.
5
+ > Born from [SAP/openui5#3411](https://github.com/SAP/openui5/issues/3411), an open request for native navigation guard support in UI5.
6
6
  >
7
7
  > **Related resources**:
8
8
  >
@@ -12,6 +12,9 @@ Drop-in replacement for `sap.m.routing.Router` that intercepts navigation **befo
12
12
  > [!WARNING]
13
13
  > 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.
14
14
 
15
+ > [!CAUTION]
16
+ > Navigation guards are a UX layer, not a security boundary. They can prevent unauthorized content flashes and steer client-side navigation, but they do **not** replace server-side authorization, backend validation, or service-level access control.
17
+
15
18
  ## Why
16
19
 
17
20
  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.
@@ -76,7 +79,7 @@ No transpile tooling, no middleware, no additional `ui5.yaml` changes.
76
79
 
77
80
  #### Option B: Transpile from source
78
81
 
79
- If you prefer to serve from TypeScript sources (e.g. for debugging with source maps), install [`ui5-tooling-transpile`](https://github.com/nicholasmackey/ui5-tooling-transpile) and enable `transpileDependencies` in your app's `ui5.yaml`:
82
+ If you prefer to serve from TypeScript sources (e.g. for debugging with source maps), install [`ui5-tooling-transpile`](https://github.com/ui5-community/ui5-ecosystem-showcase/tree/main/packages/ui5-tooling-transpile) and enable `transpileDependencies` in your app's `ui5.yaml`:
80
83
 
81
84
  ```bash
82
85
  npm install -D ui5-tooling-transpile
@@ -96,7 +99,7 @@ This transpiles the library's `.ts` sources on the fly during `ui5 serve`.
96
99
 
97
100
  #### Option C: Static serving (workaround)
98
101
 
99
- If neither option works for your setup, you can mount the pre-built resources manually using [`ui5-middleware-servestatic`](https://github.com/nicholasmackey/ui5-middleware-servestatic) (or a similar community middleware) and point it at the `dist/resources` folder in `node_modules`:
102
+ If neither option works for your setup, you can mount the pre-built resources manually using [`ui5-middleware-servestatic`](https://github.com/ui5-community/ui5-ecosystem-showcase/tree/main/packages/ui5-middleware-servestatic) (or a similar community middleware) and point it at the `dist/resources` folder in `node_modules`:
100
103
 
101
104
  ```bash
102
105
  npm install -D ui5-middleware-servestatic
@@ -171,7 +174,12 @@ export default class Component extends UIComponent {
171
174
 
172
175
  ## How it works
173
176
 
174
- The library extends [`sap.m.routing.Router`](https://sdk.openui5.org/api/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.
177
+ The library extends [`sap.m.routing.Router`](https://sdk.openui5.org/api/sap.m.routing.Router) and intercepts navigation through two entry points:
178
+
179
+ - **`navTo()` preflight**: For programmatic navigation (`router.navTo()`), guards run _before_ any hash change occurs. If a guard blocks or redirects, the hash never changes, so no history entry is created.
180
+ - **`parse()` fallback**: For browser-initiated navigation (back/forward buttons, URL bar entry, direct hash changes), guards run inside the `parse()` override after the browser has already changed the hash. If a guard blocks or redirects, the router restores the previous hash via `replaceHash()`.
181
+
182
+ Both entry points feed the same guard pipeline. There is no separate configuration. The same guard functions registered via `addGuard()`, `addRouteGuard()`, and `addLeaveGuard()` protect all navigation paths.
175
183
 
176
184
  Because it extends the mobile router directly, all existing `sap.m.routing.Router` behavior (Targets, route events, `navTo`, back navigation) works unchanged.
177
185
 
@@ -179,7 +187,7 @@ The guard pipeline stays **synchronous when all guards return plain values** and
179
187
 
180
188
  ## API
181
189
 
182
- All methods return `this` for chaining.
190
+ All guard registration and removal methods return `this` for chaining. `navigationSettled()` returns a `Promise<NavigationResult>`.
183
191
 
184
192
  ### Guard registration
185
193
 
@@ -199,6 +207,10 @@ All methods return `this` for chaining.
199
207
  | `removeRouteGuard(routeName, { beforeEnter?, beforeLeave? })` | Remove enter and/or leave guards via object form |
200
208
  | `removeLeaveGuard(routeName, fn)` | Remove a leave guard |
201
209
 
210
+ ### Unknown routes during registration
211
+
212
+ `addRouteGuard()` and `addLeaveGuard()` warn when the route name is unknown at registration time, but they still register the guard. This is intentional so applications can attach guards before dynamic `addRoute()` calls or before route definitions are finalized.
213
+
202
214
  ### GuardContext
203
215
 
204
216
  Every guard receives a `GuardContext` object:
@@ -220,12 +232,12 @@ Enter guards return `GuardResult`, a union of four outcomes:
220
232
  GuardResult = boolean | string | GuardRedirect
221
233
  ```
222
234
 
223
- | Return | Type | When to use | Effect |
224
- | ---------------------------------------------- | --------------- | ------------------------------------------------------- | ----------------------------------------------- |
225
- | `true` | `boolean` | Guard condition passes | Allow navigation |
226
- | `false` | `boolean` | Guard condition fails, no specific destination | Block (stay on current route, no history entry) |
227
- | `"routeName"` | `string` | Redirect to a fixed route (no parameters needed) | Redirect to named route (replaces history) |
228
- | `{ route, parameters?, componentTargetInfo? }` | `GuardRedirect` | Redirect and pass route parameters or component targets | Redirect with parameters (replaces history) |
235
+ | Return | Type | When to use | Effect |
236
+ | ---------------------------------------------- | --------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
237
+ | `true` | `boolean` | Guard condition passes | Allow navigation |
238
+ | `false` | `boolean` | Guard condition fails, no specific destination | Stay on current route. Programmatic `navTo()` creates no history entry. Browser-initiated navigation restores the previous hash. |
239
+ | `"routeName"` | `string` | Redirect to a fixed route (no parameters needed) | Navigate to target route. Programmatic `navTo()` goes directly to target with no intermediate entry. Browser-initiated navigation replaces the current hash. |
240
+ | `{ route, parameters?, componentTargetInfo? }` | `GuardRedirect` | Redirect and pass route parameters or component targets | Same as string redirect, with parameters |
229
241
 
230
242
  `GuardRedirect` is the object form of a redirect. Use it when you need to pass route parameters (`parameters`) or nested component targets (`componentTargetInfo`). For simple redirects without parameters, the string shorthand (`return "home"`) is equivalent and shorter.
231
243
 
@@ -260,16 +272,19 @@ import NavigationOutcome from "ui5/guard/router/NavigationOutcome";
260
272
  const result: NavigationResult = await router.navigationSettled();
261
273
  ```
262
274
 
263
- | `result.status` | Meaning |
264
- | ------------------------------ | ---------------------------------------------------------------------------- |
265
- | `NavigationOutcome.Committed` | Guards allowed the navigation; target route is now active |
266
- | `NavigationOutcome.Blocked` | A guard blocked navigation; previous route stays active |
267
- | `NavigationOutcome.Redirected` | A guard redirected navigation to a different route |
268
- | `NavigationOutcome.Cancelled` | Navigation was cancelled before settling (superseded, stopped, or destroyed) |
275
+ | `result.status` | Meaning |
276
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
277
+ | `NavigationOutcome.Committed` | Guards allowed the navigation; target route is now active |
278
+ | `NavigationOutcome.Bypassed` | Guards allowed the navigation, but no route matched; UI5 continued with `bypassed` / not-found handling |
279
+ | `NavigationOutcome.Blocked` | A guard blocked navigation; previous route stays active |
280
+ | `NavigationOutcome.Redirected` | A guard redirected navigation to a different route |
281
+ | `NavigationOutcome.Cancelled` | Navigation was cancelled before settling (superseded, stopped, or destroyed) |
282
+
283
+ A guard redirect that fails to trigger a follow-up navigation settles as `Blocked` because no route change commits. A nonexistent route name is the most common cause, and the router logs the target name to help diagnose it.
269
284
 
270
- A guard redirect to a nonexistent route name settles as `Blocked` because no route change commits. The router logs a warning with the bad target name.
285
+ An accepted unmatched hash settles as `Bypassed` with `route === ""` and the attempted hash preserved in `hash`. Idle `navigationSettled()` calls replay that `Bypassed` result until another navigation settles, matching the existing replay behavior for the other outcomes.
271
286
 
272
- If no navigation is in flight, `navigationSettled()` resolves immediately with the most recent settlement result. That makes it safe to call right after `navTo()`, even when guards settle synchronously. On a fresh router (or after `stop()`/`destroy()`), this defaults to `Committed` with the current state. Multiple callers waiting on the same pending navigation all receive the same result.
287
+ If no navigation is in flight, `navigationSettled()` resolves immediately with the most recent settlement result. That makes it safe to call right after `navTo()`, even when guards settle synchronously. On a fresh router, this defaults to `Committed` with the instance's current route/hash state. After `stop()`, those fields are reset, so idle calls resolve with empty strings until the next navigation settles. Multiple callers waiting on the same pending navigation all receive the same result.
273
288
 
274
289
  **App code: busy indicator during async guards**
275
290
 
@@ -290,6 +305,9 @@ const result = await router.navigationSettled();
290
305
  switch (result.status) {
291
306
  case NavigationOutcome.Committed:
292
307
  break; // navigation succeeded
308
+ case NavigationOutcome.Bypassed:
309
+ MessageToast.show("No route matched; showing not-found flow");
310
+ break;
293
311
  case NavigationOutcome.Blocked:
294
312
  MessageToast.show("Access denied");
295
313
  break;
@@ -310,6 +328,21 @@ assert.strictEqual(result.status, NavigationOutcome.Blocked, "Navigation was blo
310
328
  assert.strictEqual(result.route, "home", "User stays on home");
311
329
  ```
312
330
 
331
+ **Event-based: observe every navigation outcome**
332
+
333
+ `attachNavigationSettled` fires synchronously after every guard pipeline settlement. Unlike the one-shot `navigationSettled()` Promise, the event fires for every navigation without re-registration:
334
+
335
+ ```typescript
336
+ router.attachNavigationSettled((event) => {
337
+ const status = event.getParameter("status"); // NavigationOutcome
338
+ const route = event.getParameter("route");
339
+ const hash = event.getParameter("hash");
340
+ console.log(`Navigation settled: ${status} on ${route}`);
341
+ });
342
+ ```
343
+
344
+ Use `detachNavigationSettled(fnFunction, oListener)` to remove the listener. The same function and listener references must match those passed to `attachNavigationSettled`. The event uses UI5's native `EventProvider` mechanism, so the standard `attachEvent` / `detachEvent` pattern also works.
345
+
313
346
  ### Execution order
314
347
 
315
348
  1. **Leave guards** for the current route (registration order)
@@ -349,8 +382,12 @@ router.addGuard((context): GuardRedirect | true => {
349
382
  });
350
383
  ```
351
384
 
385
+ The demo app keeps `createRedirectWithParamsGuard()` as a reference implementation in `packages/demo-app/webapp/guards.ts`; the runnable demo routes do not use it because they have no route parameters.
386
+
352
387
  ### Guard factories
353
388
 
389
+ The demo app keeps reusable guard factories in `packages/demo-app/webapp/guards.ts`. `createDirtyFormGuard()` is actively registered as a leave guard on the `"protected"` route; `createAuthGuard()` is a reference-only synchronous alternative to the async `createAsyncPermissionGuard()` used by the demo.
390
+
354
391
  ```typescript
355
392
  // guards.ts
356
393
  import JSONModel from "sap/ui/model/json/JSONModel";
@@ -371,16 +408,18 @@ export function createDirtyFormGuard(formModel: JSONModel): LeaveGuardFn {
371
408
 
372
409
  ### Object form with RouteGuardConfig
373
410
 
411
+ The runnable demo uses the same object form in `packages/demo-app/webapp/Component.ts`, pairing an async permission check with a leave guard on the `protected` route.
412
+
374
413
  ```typescript
375
414
  import type { RouteGuardConfig } from "ui5/guard/router/types";
376
415
 
377
- const orderGuards: RouteGuardConfig = {
378
- beforeEnter: createAuthGuard(authModel),
416
+ const protectedGuards: RouteGuardConfig = {
417
+ beforeEnter: createAsyncPermissionGuard(authModel),
379
418
  beforeLeave: createDirtyFormGuard(formModel),
380
419
  };
381
420
 
382
- router.addRouteGuard("editOrder", orderGuards);
383
- // later: router.removeRouteGuard("editOrder", orderGuards);
421
+ router.addRouteGuard("protected", protectedGuards);
422
+ // later: router.removeRouteGuard("protected", protectedGuards);
384
423
  ```
385
424
 
386
425
  ### Dynamic guard registration
@@ -400,25 +439,28 @@ router.removeGuard(logGuard);
400
439
 
401
440
  ### Leave guard with controller lifecycle
402
441
 
442
+ The demo app shows the same lifecycle pattern in `packages/demo-app/webapp/controller/Home.controller.ts`, registering `createHomeLeaveLogger()` on the `home` route and removing it again in `onExit()`.
443
+
403
444
  ```typescript
404
445
  import type { GuardRouter, LeaveGuardFn } from "ui5/guard/router/types";
405
- import { createDirtyFormGuard } from "./guards";
446
+ import BaseController from "./BaseController";
447
+ import { createHomeLeaveLogger } from "../guards";
406
448
 
407
- export default class EditOrderController extends Controller {
408
- private _leaveGuard: LeaveGuardFn;
449
+ export default class HomeController extends BaseController {
450
+ private _leaveGuard: LeaveGuardFn | null = null;
409
451
 
410
452
  onInit(): void {
411
- const formModel = new JSONModel({ isDirty: false });
412
- this.getView()!.setModel(formModel, "form");
413
-
414
- const router = UIComponent.getRouterFor(this) as GuardRouter;
415
- this._leaveGuard = createDirtyFormGuard(formModel);
416
- router.addLeaveGuard("editOrder", this._leaveGuard);
453
+ const router = this.getRouter<GuardRouter>();
454
+ this._leaveGuard = createHomeLeaveLogger();
455
+ router.addLeaveGuard("home", this._leaveGuard);
417
456
  }
418
457
 
419
458
  onExit(): void {
420
- const router = UIComponent.getRouterFor(this) as GuardRouter;
421
- router.removeLeaveGuard("editOrder", this._leaveGuard);
459
+ if (this._leaveGuard) {
460
+ const router = this.getRouter<GuardRouter>();
461
+ router.removeLeaveGuard("home", this._leaveGuard);
462
+ this._leaveGuard = null;
463
+ }
422
464
  }
423
465
  }
424
466
  ```
@@ -523,31 +565,43 @@ router.addRouteGuard("dashboard", (context) => {
523
565
  });
524
566
  ```
525
567
 
526
- ### URL bar shows target hash during async guards
568
+ ### History guarantees differ by navigation source
569
+
570
+ Programmatic `router.navTo()` calls get clean history: blocked or redirected navigations create no history entry. Browser back/forward and URL bar entry may leave an extra history entry because the browser changes the hash before guards can intercept. The guard still protects the route, but the browser history may contain a duplicate entry that the router repairs via `replaceHash()`.
527
571
 
528
- 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.
572
+ ### URL bar shows target hash during async guards (browser-initiated only)
529
573
 
530
- This does **not** affect sync guards, which resolve in the same tick as the hash change (the URL flicker is imperceptible).
574
+ For browser-initiated navigation (back/forward, URL bar entry, direct hash changes), the URL bar shows the target hash while an async guard resolves. If the guard blocks or redirects, the URL reverts via `replaceHash()`. There is a brief window where the displayed URL does not match the active route.
531
575
 
532
- **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.
576
+ This does **not** apply to programmatic `navTo()` calls, where the hash does not change until guards approve. It also does not affect sync guards on the `parse()` path, which resolve in the same tick as the hash change.
577
+
578
+ **Why the parse() path cannot prevent this**: UI5's `HashChanger` updates the URL and fires `hashChanged` before `parse()` runs. 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 does not allow this without intercepting at the HashChanger level, which is globally scoped and fragile.
533
579
 
534
580
  ```
535
- User clicks link / navTo()
536
-
537
- HashChanger updates browser URL ← URL changes HERE
538
-
539
- HashChanger fires hashChanged
540
-
541
- Router.parse() called ← guards run HERE
542
-
543
- ┌────┴────┐
544
- allowed blocked
545
- ↓ ↓
546
- views _restoreHash()
547
- load reverts URL
581
+ Browser-initiated navigation (back/forward, URL bar, setHash):
582
+ HashChanger updates browser URL ← URL changes HERE
583
+
584
+ HashChanger fires hashChanged
585
+
586
+ Router.parse() called ← guards run HERE
587
+
588
+ ┌────┴────┐
589
+ allowed blocked
590
+ ↓ ↓
591
+ views _restoreHash()
592
+ load reverts URL
593
+
594
+ Programmatic navigation (navTo):
595
+ navTo() called ← guards run HERE
596
+
597
+ ┌────┴────┐
598
+ allowed blocked
599
+ ↓ ↓
600
+ super.navTo() return
601
+ hash changes (no hash change)
548
602
  ```
549
603
 
550
- 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:
604
+ For the `parse()` path, show a busy indicator while async guards resolve. This communicates that navigation is in progress, making the URL bar state a non-issue:
551
605
 
552
606
  ```typescript
553
607
  router.addRouteGuard("dashboard", async (context) => {
@@ -567,12 +621,67 @@ router.addRouteGuard("dashboard", async (context) => {
567
621
 
568
622
  This follows the same pattern as [TanStack Router's `pendingComponent`](https://tanstack.com/router/latest/docs/framework/react/guide/navigation-blocking#handling-blocked-navigations): the URL reflects the intent while a loading state signals that the navigation hasn't committed yet.
569
623
 
624
+ ## Debugging and Troubleshooting
625
+
626
+ ### Enabling guard logs
627
+
628
+ The router logs guard registration errors, pipeline decisions, and async discard events through UI5's `Log` API under the component name `ui5.guard.router.Router`.
629
+
630
+ Enable debug-level output programmatically:
631
+
632
+ ```typescript
633
+ import Log from "sap/base/Log";
634
+ Log.setLevel(Log.Level.DEBUG, "ui5.guard.router.Router");
635
+ ```
636
+
637
+ Or set the global log level via URL parameter (per-component filtering is only available through the programmatic API above):
638
+
639
+ ```
640
+ ?sap-ui-log-level=DEBUG
641
+ ```
642
+
643
+ > **Note**: UI5 1.120+ uses kebab-case URL parameters (`sap-ui-log-level`). Older versions use camelCase (`sap-ui-logLevel`).
644
+
645
+ ### Log reference
646
+
647
+ | Level | Message | Trigger |
648
+ | ------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
649
+ | warning | `addGuard called with invalid guard, ignoring` | Non-function passed to `addGuard()` |
650
+ | warning | `addRouteGuard called with invalid guard, ignoring` | Non-function `beforeEnter`, `beforeLeave`, or direct guard |
651
+ | info | `addRouteGuard called with config missing both beforeEnter and beforeLeave` | Empty `RouteGuardConfig` object (no handlers) |
652
+ | warning | `addLeaveGuard called with invalid guard, ignoring` | Non-function passed to `addLeaveGuard()` |
653
+ | warning | `removeGuard called with invalid guard, ignoring` | Non-function passed to `removeGuard()` |
654
+ | warning | `removeRouteGuard called with invalid guard, ignoring` | Non-function passed to `removeRouteGuard()` |
655
+ | warning | `removeLeaveGuard called with invalid guard, ignoring` | Non-function passed to `removeLeaveGuard()` |
656
+ | warning | `{method} called for unknown route; guard will still register...` | Route name not found at registration time |
657
+ | warning | `Guard returned invalid value, treating as block` | Enter guard returned something other than `true`, `false`, a non-empty string, or a `GuardRedirect` |
658
+ | warning | `Leave guard returned non-boolean value, treating as block` | Leave guard returned something other than `true` or `false` |
659
+ | warning | `Guard redirect target "{route}" did not produce a navigation, treating as blocked` | Redirect target did not trigger a follow-up navigation (most commonly an unknown route name) |
660
+ | error | `Guard pipeline failed for "{hash}", blocking navigation` | Async guard pipeline rejected in `parse()` fallback path |
661
+ | error | `Async preflight guard failed for route "{route}", blocking navigation` | Async guard pipeline rejected in `navTo()` preflight path |
662
+ | error | `Enter guard [{n}] for/on route "{route}" threw, blocking navigation` | Sync or async enter guard threw an exception |
663
+ | error | `Leave guard [{n}] on route "{route}" threw, blocking navigation` | Sync or async leave guard threw an exception |
664
+ | debug | `Async guard result discarded (superseded by newer navigation)` | A newer navigation invalidated the pending async result (`parse()` path) |
665
+ | debug | `Async preflight result discarded (superseded by newer navigation)` | A newer navigation invalidated the pending async result (`navTo()` path) |
666
+
667
+ ### Common issues
668
+
669
+ **Guards not running**: Verify the route name passed to `addRouteGuard()` matches the route name in `manifest.json`, not the pattern or target name. If the guard is on a redirect target, it does not run -- see [Redirect targets bypass guards](#redirect-targets-bypass-guards).
670
+
671
+ **Navigation blocked unexpectedly**: Only a strict `true` return value allows navigation. Returning `undefined`, `null`, or omitting a return statement blocks. Enable debug-level logging to identify which guard blocked.
672
+
673
+ **Redirect treated as blocked**: The redirect did not trigger a follow-up navigation. Most often the target route name is wrong, but a same-hash no-op can look similar. The router logs the target name so you can verify the route and parameters.
674
+
675
+ **Async guard result discarded**: A newer navigation started before the async guard resolved. The router uses a generation counter to discard stale results. This is expected behavior during rapid sequential navigations. The debug log confirms when this occurs.
676
+
677
+ **URL bar shows target hash, then reverts**: This is expected for async guards. The `HashChanger` updates the URL before `parse()` runs. See [URL bar shows target hash during async guards](#url-bar-shows-target-hash-during-async-guards) for the architectural explanation and the busy-indicator pattern.
678
+
570
679
  ## Compatibility
571
680
 
572
681
  > [!IMPORTANT]
573
682
  > **Shipped UI5 baseline: 1.144.0**
574
683
  >
575
- > The published package declares `minUI5Version: 1.144.0`, and the full CI suite runs on that shipped baseline. In addition, CI runs the library QUnit suite against OpenUI5 `1.120.0` as a compatibility lane for the core router implementation. The compatibility baseline is 1.120 because `DataType.registerEnum` (used for the `NavigationOutcome` enum) requires that version. That extra lane does not change the published manifest baseline, but it provides a concrete verification signal for consumers evaluating older runtimes.
684
+ > The published package declares `minUI5Version: 1.144.0`, and the full CI suite runs on that shipped baseline. In addition, CI runs the library QUnit suite against OpenUI5 `1.120.0` as a compatibility lane for the core router implementation. The compatibility baseline is 1.120 because `DataType.registerEnum` (used for the `NavigationOutcome` enum) requires that version. The shipped baseline also carries a vendored OpenUI5 router parity lane for inherited `sap.m.routing.Router` behavior when no guards are active.
576
685
 
577
686
  If you maintain an app on an older UI5 stack and want to validate locally, run the dedicated compatibility check from the monorepo root:
578
687
 
@@ -580,6 +689,12 @@ If you maintain an app on an older UI5 stack and want to validate locally, run t
580
689
  npm run test:qunit:compat:120
581
690
  ```
582
691
 
692
+ The vendored parity tests run as part of the main QUnit suite:
693
+
694
+ ```bash
695
+ npm run test:qunit
696
+ ```
697
+
583
698
  ## License
584
699
 
585
700
  [MIT](LICENSE)
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "buildManifest": {
18
18
  "manifestVersion": "0.2",
19
- "timestamp": "2026-03-17T12:41:14.778Z",
19
+ "timestamp": "2026-03-20T13:27:11.510Z",
20
20
  "versions": {
21
21
  "builderVersion": "4.1.4",
22
22
  "projectVersion": "4.0.13",
@@ -31,7 +31,7 @@
31
31
  "includedTasks": [],
32
32
  "excludedTasks": []
33
33
  },
34
- "version": "1.2.0",
34
+ "version": "1.3.0",
35
35
  "namespace": "ui5/guard/router",
36
36
  "tags": {
37
37
  "/resources/ui5/guard/router/library-dbg.js": {
package/dist/index.d.ts CHANGED
@@ -12,23 +12,27 @@
12
12
  // - @types/yargs@17.0.35
13
13
  // - @types/ws@8.18.1
14
14
  // - @types/which@2.0.2
15
+ // - @types/unist@2.0.11
15
16
  // - @types/three@0.125.3
16
17
  // - @types/stack-utils@2.0.3
17
18
  // - @types/sizzle@2.3.10
18
19
  // - @types/sinonjs__fake-timers@8.1.5
20
+ // - @types/sinon@21.0.0
19
21
  // - @types/shimmer@1.2.0
20
22
  // - @types/qunit@2.5.4
21
23
  // - @types/offscreencanvas@2019.6.4
24
+ // - @types/npm-package-arg@6.1.4
22
25
  // - @types/normalize-package-data@2.4.4
23
26
  // - @types/node@25.5.0
24
27
  // - @types/mocha@10.0.10
28
+ // - @types/minimist@1.2.5
25
29
  // - @types/minimatch@3.0.5
26
30
  // - @types/jquery@3.5.13
27
31
  // - @types/istanbul-reports@3.0.4
28
32
  // - @types/istanbul-lib-report@3.0.3
29
33
  // - @types/istanbul-lib-coverage@2.0.6
30
34
  // - @types/qunit@2.19.13
31
- /// <reference path="./resources/ui5/guard/router/Router.d.ts"/>
32
- /// <reference path="./resources/ui5/guard/router/library.d.ts"/>
33
35
  /// <reference path="./resources/ui5/guard/router/NavigationOutcome.d.ts"/>
34
- /// <reference path="./resources/ui5/guard/router/types.d.ts"/>
36
+ /// <reference path="./resources/ui5/guard/router/library.d.ts"/>
37
+ /// <reference path="./resources/ui5/guard/router/types.d.ts"/>
38
+ /// <reference path="./resources/ui5/guard/router/Router.d.ts"/>
@@ -2,7 +2,7 @@
2
2
  <library xmlns="http://www.sap.com/sap.ui.library.xsd">
3
3
  <name>ui5.guard.router</name>
4
4
  <vendor>Marco</vendor>
5
- <version>1.2.0</version>
5
+ <version>1.3.0</version>
6
6
  <copyright></copyright>
7
7
  <title>UI5 Router extension with async navigation guards</title>
8
8
  <documentation>Extends sap.m.routing.Router with async navigation guards, running before route matching begins.</documentation>
@@ -14,6 +14,8 @@ sap.ui.define([], function () {
14
14
  const NavigationOutcome = Object.freeze({
15
15
  /** Navigation was allowed and the target route activated. */
16
16
  Committed: "committed",
17
+ /** Navigation was allowed, but no route matched; UI5 continued with bypassed handling. */
18
+ Bypassed: "bypassed",
17
19
  /** A guard blocked navigation; the previous route remains active. */
18
20
  Blocked: "blocked",
19
21
  /** A guard redirected navigation to a different route. */
@@ -1 +1 @@
1
- {"version":3,"file":"NavigationOutcome-dbg.js","names":["NavigationOutcome","Object","freeze","Committed","Blocked","Redirected","Cancelled"],"sources":["NavigationOutcome.ts"],"sourcesContent":["/**\n * Outcome of a navigation after the guard pipeline settles.\n *\n * Registered as a UI5 enum via `library.ts` so that it is discoverable\n * through `sap.ui.base.DataType.getType()`. Application code can import\n * the value object directly.\n *\n * @enum {string}\n * @namespace ui5.guard.router\n */\nconst NavigationOutcome = Object.freeze({\n\t/** Navigation was allowed and the target route activated. */\n\tCommitted: \"committed\" as const,\n\t/** A guard blocked navigation; the previous route remains active. */\n\tBlocked: \"blocked\" as const,\n\t/** A guard redirected navigation to a different route. */\n\tRedirected: \"redirected\" as const,\n\t/** Navigation was cancelled before settling (superseded, stopped, or destroyed). */\n\tCancelled: \"cancelled\" as const,\n});\n\ntype NavigationOutcome = (typeof NavigationOutcome)[keyof typeof NavigationOutcome];\n\nexport default NavigationOutcome;\n"],"mappings":";;;EAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;EACA,MAAMA,iBAAiB,GAAGC,MAAM,CAACC,MAAM,CAAC;IACvC;IACAC,SAAS,EAAE,WAAoB;IAC/B;IACAC,OAAO,EAAE,SAAkB;IAC3B;IACAC,UAAU,EAAE,YAAqB;IACjC;IACAC,SAAS,EAAE;EACZ,CAAC,CAAC;EAAC,OAIYN,iBAAiB;AAAA","ignoreList":[]}
1
+ {"version":3,"file":"NavigationOutcome-dbg.js","names":["NavigationOutcome","Object","freeze","Committed","Bypassed","Blocked","Redirected","Cancelled"],"sources":["NavigationOutcome.ts"],"sourcesContent":["/**\n * Outcome of a navigation after the guard pipeline settles.\n *\n * Registered as a UI5 enum via `library.ts` so that it is discoverable\n * through `sap.ui.base.DataType.getType()`. Application code can import\n * the value object directly.\n *\n * @enum {string}\n * @namespace ui5.guard.router\n */\nconst NavigationOutcome = Object.freeze({\n\t/** Navigation was allowed and the target route activated. */\n\tCommitted: \"committed\",\n\t/** Navigation was allowed, but no route matched; UI5 continued with bypassed handling. */\n\tBypassed: \"bypassed\",\n\t/** A guard blocked navigation; the previous route remains active. */\n\tBlocked: \"blocked\",\n\t/** A guard redirected navigation to a different route. */\n\tRedirected: \"redirected\",\n\t/** Navigation was cancelled before settling (superseded, stopped, or destroyed). */\n\tCancelled: \"cancelled\",\n});\n\ntype NavigationOutcome = (typeof NavigationOutcome)[keyof typeof NavigationOutcome];\n\nexport default NavigationOutcome;\n"],"mappings":";;;EAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;EACA,MAAMA,iBAAiB,GAAGC,MAAM,CAACC,MAAM,CAAC;IACvC;IACAC,SAAS,EAAE,WAAW;IACtB;IACAC,QAAQ,EAAE,UAAU;IACpB;IACAC,OAAO,EAAE,SAAS;IAClB;IACAC,UAAU,EAAE,YAAY;IACxB;IACAC,SAAS,EAAE;EACZ,CAAC,CAAC;EAAC,OAIYP,iBAAiB;AAAA","ignoreList":[]}
@@ -12,6 +12,8 @@ declare module "ui5/guard/router/NavigationOutcome" {
12
12
  const NavigationOutcome: Readonly<{
13
13
  /** Navigation was allowed and the target route activated. */
14
14
  Committed: "committed";
15
+ /** Navigation was allowed, but no route matched; UI5 continued with bypassed handling. */
16
+ Bypassed: "bypassed";
15
17
  /** A guard blocked navigation; the previous route remains active. */
16
18
  Blocked: "blocked";
17
19
  /** A guard redirected navigation to a different route. */
@@ -1 +1 @@
1
- {"version":3,"file":"NavigationOutcome.d.ts","sourceRoot":"../../../../..","sources":["src/NavigationOutcome.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,oCAAoC,CAAC;IACpD;;;;;;;;;OASG;IACH,MAAM,iBAAiB;QACtB,6DAA6D;;QAE7D,qEAAqE;;QAErE,0DAA0D;;QAE1D,oFAAoF;;MAEnF,CAAC;IAEH,KAAK,iBAAiB,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC;IAEpF,eAAe,iBAAiB,CAAC;CAEhC"}
1
+ {"version":3,"file":"NavigationOutcome.d.ts","sourceRoot":"../../../../..","sources":["src/NavigationOutcome.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,oCAAoC,CAAC;IACpD;;;;;;;;;OASG;IACH,MAAM,iBAAiB;QACtB,6DAA6D;;QAE7D,0FAA0F;;QAE1F,qEAAqE;;QAErE,0DAA0D;;QAE1D,oFAAoF;;MAEnF,CAAC;IAEH,KAAK,iBAAiB,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC;IAEpF,eAAe,iBAAiB,CAAC;CAEhC"}
@@ -1,2 +1,2 @@
1
- sap.ui.define([],function(){"use strict";const e=Object.freeze({Committed:"committed",Blocked:"blocked",Redirected:"redirected",Cancelled:"cancelled"});return e});
1
+ sap.ui.define([],function(){"use strict";const e=Object.freeze({Committed:"committed",Bypassed:"bypassed",Blocked:"blocked",Redirected:"redirected",Cancelled:"cancelled"});return e});
2
2
  //# sourceMappingURL=NavigationOutcome.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"NavigationOutcome.js","names":["NavigationOutcome","Object","freeze","Committed","Blocked","Redirected","Cancelled"],"sources":["NavigationOutcome.ts"],"sourcesContent":["/**\n * Outcome of a navigation after the guard pipeline settles.\n *\n * Registered as a UI5 enum via `library.ts` so that it is discoverable\n * through `sap.ui.base.DataType.getType()`. Application code can import\n * the value object directly.\n *\n * @enum {string}\n * @namespace ui5.guard.router\n */\nconst NavigationOutcome = Object.freeze({\n\t/** Navigation was allowed and the target route activated. */\n\tCommitted: \"committed\" as const,\n\t/** A guard blocked navigation; the previous route remains active. */\n\tBlocked: \"blocked\" as const,\n\t/** A guard redirected navigation to a different route. */\n\tRedirected: \"redirected\" as const,\n\t/** Navigation was cancelled before settling (superseded, stopped, or destroyed). */\n\tCancelled: \"cancelled\" as const,\n});\n\ntype NavigationOutcome = (typeof NavigationOutcome)[keyof typeof NavigationOutcome];\n\nexport default NavigationOutcome;\n"],"mappings":"yCAUA,MAAMA,EAAoBC,OAAOC,OAAO,CAEvCC,UAAW,YAEXC,QAAS,UAETC,WAAY,aAEZC,UAAW,cACT,OAIYN,CAAiB","ignoreList":[]}
1
+ {"version":3,"file":"NavigationOutcome.js","names":["NavigationOutcome","Object","freeze","Committed","Bypassed","Blocked","Redirected","Cancelled"],"sources":["NavigationOutcome.ts"],"sourcesContent":["/**\n * Outcome of a navigation after the guard pipeline settles.\n *\n * Registered as a UI5 enum via `library.ts` so that it is discoverable\n * through `sap.ui.base.DataType.getType()`. Application code can import\n * the value object directly.\n *\n * @enum {string}\n * @namespace ui5.guard.router\n */\nconst NavigationOutcome = Object.freeze({\n\t/** Navigation was allowed and the target route activated. */\n\tCommitted: \"committed\",\n\t/** Navigation was allowed, but no route matched; UI5 continued with bypassed handling. */\n\tBypassed: \"bypassed\",\n\t/** A guard blocked navigation; the previous route remains active. */\n\tBlocked: \"blocked\",\n\t/** A guard redirected navigation to a different route. */\n\tRedirected: \"redirected\",\n\t/** Navigation was cancelled before settling (superseded, stopped, or destroyed). */\n\tCancelled: \"cancelled\",\n});\n\ntype NavigationOutcome = (typeof NavigationOutcome)[keyof typeof NavigationOutcome];\n\nexport default NavigationOutcome;\n"],"mappings":"yCAUA,MAAMA,EAAoBC,OAAOC,OAAO,CAEvCC,UAAW,YAEXC,SAAU,WAEVC,QAAS,UAETC,WAAY,aAEZC,UAAW,cACT,OAIYP,CAAiB","ignoreList":[]}