ui5-lib-guard-router 1.3.2 → 1.5.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 (38) hide show
  1. package/README.md +260 -50
  2. package/dist/.ui5/build-manifest.json +2 -2
  3. package/dist/index.d.ts +3 -3
  4. package/dist/resources/ui5/guard/router/.library +1 -1
  5. package/dist/resources/ui5/guard/router/GuardPipeline-dbg.js +80 -22
  6. package/dist/resources/ui5/guard/router/GuardPipeline-dbg.js.map +1 -1
  7. package/dist/resources/ui5/guard/router/GuardPipeline.d.ts +38 -1
  8. package/dist/resources/ui5/guard/router/GuardPipeline.d.ts.map +1 -1
  9. package/dist/resources/ui5/guard/router/GuardPipeline.js +1 -1
  10. package/dist/resources/ui5/guard/router/GuardPipeline.js.map +1 -1
  11. package/dist/resources/ui5/guard/router/NavigationOutcome-dbg.js +3 -1
  12. package/dist/resources/ui5/guard/router/NavigationOutcome-dbg.js.map +1 -1
  13. package/dist/resources/ui5/guard/router/NavigationOutcome.d.ts +2 -0
  14. package/dist/resources/ui5/guard/router/NavigationOutcome.d.ts.map +1 -1
  15. package/dist/resources/ui5/guard/router/NavigationOutcome.js +1 -1
  16. package/dist/resources/ui5/guard/router/NavigationOutcome.js.map +1 -1
  17. package/dist/resources/ui5/guard/router/Router-dbg.js +755 -76
  18. package/dist/resources/ui5/guard/router/Router-dbg.js.map +1 -1
  19. package/dist/resources/ui5/guard/router/Router.d.ts +184 -8
  20. package/dist/resources/ui5/guard/router/Router.d.ts.map +1 -1
  21. package/dist/resources/ui5/guard/router/Router.js +1 -1
  22. package/dist/resources/ui5/guard/router/Router.js.map +1 -1
  23. package/dist/resources/ui5/guard/router/library-dbg.js +1 -1
  24. package/dist/resources/ui5/guard/router/library-dbg.js.map +1 -1
  25. package/dist/resources/ui5/guard/router/library-preload.js +5 -5
  26. package/dist/resources/ui5/guard/router/library-preload.js.map +1 -1
  27. package/dist/resources/ui5/guard/router/library.js +1 -1
  28. package/dist/resources/ui5/guard/router/library.js.map +1 -1
  29. package/dist/resources/ui5/guard/router/manifest.json +1 -1
  30. package/dist/resources/ui5/guard/router/types-dbg.js.map +1 -1
  31. package/dist/resources/ui5/guard/router/types.d.ts +95 -3
  32. package/dist/resources/ui5/guard/router/types.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/GuardPipeline.ts +74 -25
  35. package/src/NavigationOutcome.ts +2 -0
  36. package/src/Router.ts +922 -72
  37. package/src/manifest.json +1 -1
  38. package/src/types.ts +114 -3
package/README.md CHANGED
@@ -27,6 +27,9 @@ This library solves all three by intercepting at the router level, before any ro
27
27
  npm install ui5-lib-guard-router
28
28
  ```
29
29
 
30
+ > [!NOTE]
31
+ > The npm package is ~150 KB compressed (~670 KB unpacked) because it ships both pre-built distributables (`dist/`) and TypeScript sources (`src/`) to support multiple [serving options](#serving-the-library). At runtime, the browser loads only the `library-preload.js` bundle (~25 KB).
32
+
30
33
  ### TypeScript
31
34
 
32
35
  Add the library to `compilerOptions.types` so TypeScript can resolve the type declarations. If your app does not already depend on UI5 typings, install them too (`@sapui5/types` works as well):
@@ -215,14 +218,15 @@ All guard registration and removal methods return `this` for chaining. `navigati
215
218
 
216
219
  Every guard receives a `GuardContext` object:
217
220
 
218
- | Property | Type | Description |
219
- | ------------- | -------------------------------------------------- | ----------------------------------------------------------------- |
220
- | `toRoute` | `string` | Target route name (empty if no match) |
221
- | `toHash` | `string` | Raw hash being navigated to |
222
- | `toArguments` | `Record<string, string \| Record<string, string>>` | Parsed route parameters |
223
- | `fromRoute` | `string` | Current route name (empty on first navigation) |
224
- | `fromHash` | `string` | Current hash |
225
- | `signal` | `AbortSignal` | Aborted when navigation is superseded, or on `stop()`/`destroy()` |
221
+ | Property | Type | Description |
222
+ | ------------- | -------------------------------------------------- | ------------------------------------------------------------------------- |
223
+ | `toRoute` | `string` | Target route name (empty if no match) |
224
+ | `toHash` | `string` | Raw hash being navigated to |
225
+ | `toArguments` | `Record<string, string \| Record<string, string>>` | Parsed route parameters |
226
+ | `fromRoute` | `string` | Current route name (empty on first navigation) |
227
+ | `fromHash` | `string` | Current hash |
228
+ | `signal` | `AbortSignal` | Aborted when navigation is superseded, or on `stop()`/`destroy()` |
229
+ | `bag` | `Map<string, unknown>` | Shared mutable store for inter-guard data passing within one pipeline run |
226
230
 
227
231
  ### Return values (`GuardResult`)
228
232
 
@@ -279,6 +283,7 @@ const result: NavigationResult = await router.navigationSettled();
279
283
  | `NavigationOutcome.Blocked` | A guard blocked navigation; previous route stays active |
280
284
  | `NavigationOutcome.Redirected` | A guard redirected navigation to a different route |
281
285
  | `NavigationOutcome.Cancelled` | Navigation was cancelled before settling (superseded, stopped, or destroyed) |
286
+ | `NavigationOutcome.Error` | A guard threw or rejected; previous route stays active. `result.error` holds the thrown value |
282
287
 
283
288
  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.
284
289
 
@@ -314,6 +319,9 @@ switch (result.status) {
314
319
  case NavigationOutcome.Redirected:
315
320
  MessageToast.show(`Redirected to ${result.route}`);
316
321
  break;
322
+ case NavigationOutcome.Error:
323
+ MessageBox.error("Navigation failed: " + String(result.error));
324
+ break;
317
325
  case NavigationOutcome.Cancelled:
318
326
  break; // superseded by a newer navigation
319
327
  }
@@ -343,6 +351,10 @@ router.attachNavigationSettled((event) => {
343
351
 
344
352
  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
353
 
354
+ ### Error handling
355
+
356
+ When a guard throws or its Promise rejects, the navigation settles as `Error` with `result.error` containing the thrown value. The previous route stays active. This is distinct from `Blocked` (intentional denial) — `Error` indicates an unexpected failure.
357
+
346
358
  ### Execution order
347
359
 
348
360
  1. **Leave guards** for the current route (registration order)
@@ -350,6 +362,232 @@ Use `detachNavigationSettled(fnFunction, oListener)` to remove the listener. The
350
362
  3. **Route-specific enter guards** for the target (registration order)
351
363
  4. Pipeline **short-circuits** at the first non-`true` result
352
364
 
365
+ Each phase short-circuits on the first non-`true` result. If a leave guard blocks, no enter guards run. If a global guard redirects, route-specific guards are skipped.
366
+
367
+ ## Manifest Configuration
368
+
369
+ Guards can be declared directly in `manifest.json` using the `guardRouter` block inside `sap.ui5.routing.config`. This eliminates boilerplate in `Component.ts` for common guard patterns.
370
+
371
+ ```json
372
+ {
373
+ "sap.ui5": {
374
+ "routing": {
375
+ "config": {
376
+ "routerClass": "ui5.guard.router.Router",
377
+ "guardRouter": {
378
+ "unknownRouteGuardRegistration": "warn",
379
+ "navToPreflight": "guard",
380
+ "guardLoading": "lazy",
381
+ "guards": {
382
+ "*": ["guards.authGuard"],
383
+ "admin": {
384
+ "enter": ["guards.adminGuard"],
385
+ "leave": ["guards.unsavedChangesGuard"]
386
+ }
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+ }
393
+ ```
394
+
395
+ ### Router options
396
+
397
+ | Option | Values | Default | Description |
398
+ | ------------------------------- | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
399
+ | `unknownRouteGuardRegistration` | `"ignore"` \| `"warn"` \| `"throw"` | `"warn"` | What to do when a guard is declared for a route name that doesn't exist in the manifest |
400
+ | `navToPreflight` | `"guard"` \| `"bypass"` \| `"off"` | `"guard"` | Whether `navTo()` calls run through the guard pipeline (`"guard"`), skip guards (`"bypass"`), or the preflight is disabled entirely (`"off"`) |
401
+ | `guardLoading` | `"block"` \| `"lazy"` | `"lazy"` | `"lazy"`: registers lazy wrappers, loads modules on first navigation; a preload hint fires in the constructor to warm the cache; `initialize()` is always synchronous. `"block"`: loads all modules before `initialize()` completes; `initialize()` is async. |
402
+
403
+ ### Declarative guards
404
+
405
+ The `guards` map wires guard modules to routes without writing code in `Component.ts`.
406
+
407
+ **Global guards** run on every navigation and are declared under the `"*"` key:
408
+
409
+ ```json
410
+ "guards": {
411
+ "*": ["guards.authGuard"]
412
+ }
413
+ ```
414
+
415
+ **Per-route guards** use the route name as the key. The shorthand array form registers enter guards only:
416
+
417
+ ```json
418
+ "guards": {
419
+ "admin": ["guards.adminGuard"]
420
+ }
421
+ ```
422
+
423
+ The full object form with `enter` and `leave` keys registers both enter and leave guards:
424
+
425
+ ```json
426
+ "guards": {
427
+ "admin": {
428
+ "enter": ["guards.adminGuard"],
429
+ "leave": ["guards.unsavedChangesGuard"]
430
+ }
431
+ }
432
+ ```
433
+
434
+ **Module paths** use dot notation and are resolved relative to `sap.app.id`. Given `sap.app.id = "com.example.app"`, the path `"guards.authGuard"` resolves to `"com/example/app/guards/authGuard"`.
435
+
436
+ To use an absolute module path, prefix it with `"module:"`:
437
+
438
+ ```json
439
+ "*": ["module:com/shared/guards/authGuard"]
440
+ ```
441
+
442
+ ### Complete example
443
+
444
+ manifest.json:
445
+
446
+ ```json
447
+ {
448
+ "sap.ui5": {
449
+ "routing": {
450
+ "config": {
451
+ "routerClass": "ui5.guard.router.Router",
452
+ "guardRouter": {
453
+ "guards": {
454
+ "*": ["guards.authGuard"],
455
+ "admin": { "enter": ["guards.roleGuard"], "leave": ["guards.unsavedGuard"] }
456
+ }
457
+ }
458
+ }
459
+ }
460
+ }
461
+ }
462
+ ```
463
+
464
+ guards/authGuard.ts:
465
+
466
+ ```typescript
467
+ import type { GuardContext, GuardResult } from "ui5/guard/router/types";
468
+
469
+ export default function authGuard(context: GuardContext): GuardResult {
470
+ if (!isAuthenticated()) return "login";
471
+ return true;
472
+ }
473
+ ```
474
+
475
+ The router loads `guards.authGuard` relative to `sap.app.id`. For `sap.app.id = "com.example.app"`, this resolves to `com/example/app/guards/authGuard`.
476
+
477
+ ### Guard module format
478
+
479
+ Each entry in the guard array is a module whose default export is one of three shapes:
480
+
481
+ **Shape 1: Function (single guard)**
482
+
483
+ ```typescript
484
+ // guards/auth.ts
485
+ import type { GuardContext, GuardResult } from "ui5/guard/router/types";
486
+
487
+ export default function authGuard(context: GuardContext): GuardResult {
488
+ return isAuthenticated() ? true : "login";
489
+ }
490
+ ```
491
+
492
+ **Shape 2: Array (ordered guards)**
493
+
494
+ ```typescript
495
+ // guards/checks.ts — registered as "checks#0", "checks#1"
496
+ import type { GuardContext, GuardResult } from "ui5/guard/router/types";
497
+
498
+ export default [
499
+ function checkAuth(context: GuardContext): GuardResult {
500
+ return true;
501
+ },
502
+ function checkRole(context: GuardContext): GuardResult {
503
+ return false;
504
+ },
505
+ ];
506
+ ```
507
+
508
+ **Shape 3: Plain Object (named guards)**
509
+
510
+ ```typescript
511
+ // guards/security.ts — registered as "checkAuth", "checkRole"
512
+ import type { GuardContext, GuardResult } from "ui5/guard/router/types";
513
+
514
+ export default {
515
+ checkAuth(context: GuardContext): GuardResult {
516
+ /* ... */ return true;
517
+ },
518
+ checkRole(context: GuardContext): GuardResult {
519
+ /* ... */ return false;
520
+ },
521
+ };
522
+ ```
523
+
524
+ Detection: function produces a single guard, `Array` produces ordered guards, and a plain object produces named guards in key order. Non-function entries in arrays and objects are warned and skipped. Empty arrays and objects are warned and produce no guards.
525
+
526
+ ### Cherry-pick syntax
527
+
528
+ When a module exports multiple guards, you can register a subset using `#` to select by name or index:
529
+
530
+ ```json
531
+ {
532
+ "guards": {
533
+ "admin": ["guards.security#checkAuth", "guards.security#checkRole"],
534
+ "dashboard": ["guards.security"],
535
+ "settings": ["guards.checks#1"],
536
+ "*": ["guards.logging"]
537
+ }
538
+ }
539
+ ```
540
+
541
+ | Syntax | Behavior |
542
+ | ------------------------------------ | --------------------------------------------- |
543
+ | `"guards.security"` | Register all exports (key/array order) |
544
+ | `"guards.security#checkAuth"` | Register only that named export |
545
+ | `"guards.security#1"` | Register by index (array or object key order) |
546
+ | `"module:some.lib.guards#checkAuth"` | `module:` prefix composes with `#` |
547
+
548
+ When `#` is used on a single-function module, the export key is ignored with a debug message and the function is still registered.
549
+
550
+ ### Guard context `bag`
551
+
552
+ Guards in the same pipeline can share data through `context.bag`, a `Map<string, unknown>` that is created fresh for each navigation and shared across all guards in that pipeline:
553
+
554
+ ```typescript
555
+ export default function firstGuard(context: GuardContext): GuardResult {
556
+ context.bag.set("userId", getCurrentUserId());
557
+ return true;
558
+ }
559
+
560
+ export default function secondGuard(context: GuardContext): GuardResult {
561
+ const userId = context.bag.get("userId") as string | undefined;
562
+ if (!userId) return "login";
563
+ return true;
564
+ }
565
+ ```
566
+
567
+ The bag is typed as `Map<string, unknown>`, so consumers cast on `.get()`. This matches how UI5 handles untyped model data (`getProperty()` returns `any`) and avoids generic complexity that can't flow through UI5's class system.
568
+
569
+ This is useful for avoiding repeated work (such as fetching the current user) when multiple guards need the same data in a single navigation.
570
+
571
+ ### `skipGuards` option
572
+
573
+ Pass `{ skipGuards: true }` as the fourth argument to `navTo()` to bypass all guards for a single call. Use this for internal redirects or navigations that should not be subject to guard logic:
574
+
575
+ ```typescript
576
+ router.navTo("settings", {}, false, { skipGuards: true });
577
+ ```
578
+
579
+ ### Mixing declarative and programmatic guards
580
+
581
+ Manifest guards and programmatic guards coexist on the same pipeline. Manifest guards are registered during `initialize()` (before the first navigation), and programmatic guards are added whenever `addGuard()` / `addRouteGuard()` / `addLeaveGuard()` is called.
582
+
583
+ **Execution order:** manifest guards run first (in declaration order), then programmatic guards (in registration order). For the same route, both sets execute — they are additive, not exclusive.
584
+
585
+ A common pattern is to declare static guards in the manifest and add context-dependent guards programmatically:
586
+
587
+ - **Manifest:** guards that don't need component state (simple blocks, redirects, logging)
588
+ - **Programmatic:** guards that close over models, services, or runtime state
589
+ - **Controller-level:** guards tied to a specific view's lifecycle (registered in `onInit`, removed in `onExit`)
590
+
353
591
  ## Examples
354
592
 
355
593
  ### Async guard with AbortSignal
@@ -408,7 +646,7 @@ export function createDirtyFormGuard(formModel: JSONModel): LeaveGuardFn {
408
646
 
409
647
  ### Object form with RouteGuardConfig
410
648
 
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.
649
+ The object form is useful when registering both enter and leave guards for the same route in a single call:
412
650
 
413
651
  ```typescript
414
652
  import type { RouteGuardConfig } from "ui5/guard/router/types";
@@ -537,33 +775,25 @@ No `toRoute` check or FLP detection is needed in the leave guard. Cross-app navi
537
775
 
538
776
  See the [FLP Dirty State Research](../../docs/research/flp-dirty-state.md) for a detailed analysis of the FLP internals.
539
777
 
540
- ## Limitations
541
-
542
- ### Redirect targets bypass guards
778
+ ## Redirect chains
543
779
 
544
- When a guard redirects navigation from route A to route B, route B's guards are **not** evaluated. The redirect commits immediately.
545
-
546
- This matters when the redirect target has its own guards. For example:
780
+ When a guard redirects navigation from route A to route B, the router evaluates route B's guards before committing. If route B also redirects, the chain continues. Leave guards are skipped on redirect hops (they only run on the first navigation), but global and route-specific enter guards run on every hop.
547
781
 
548
782
  ```
549
783
  User navigates to "dashboard"
550
784
  → dashboard guard checks permissions, returns "profile"
551
- → profile guard checks onboarding status ← this guard is SKIPPED
552
- profile view renders
785
+ → profile guard checks onboarding status ← this guard RUNS
786
+ onboarding guard allows → onboarding view renders
553
787
  ```
554
788
 
555
- 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).
789
+ Two safeguards prevent infinite redirect loops:
556
790
 
557
- 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:
791
+ - **Visited-set detection**: The router tracks every hash evaluated in the current chain. Revisiting a hash is treated as a loop and blocks the navigation.
792
+ - **Depth cap** (10 hops): Chains that exceed 10 redirect hops are blocked, even if every hash is unique. This guards against unbounded chains with parameterized routes.
558
793
 
559
- ```typescript
560
- router.addRouteGuard("dashboard", (context) => {
561
- if (!hasPermission()) {
562
- return isOnboarded() ? "profile" : "onboarding";
563
- }
564
- return true;
565
- });
566
- ```
794
+ Both safeguards log an error and settle the navigation as `Blocked`.
795
+
796
+ ## Limitations
567
797
 
568
798
  ### History guarantees differ by navigation source
569
799
 
@@ -642,36 +872,16 @@ Or set the global log level via URL parameter (per-component filtering is only a
642
872
 
643
873
  > **Note**: UI5 1.120+ uses kebab-case URL parameters (`sap-ui-log-level`). Older versions use camelCase (`sap-ui-logLevel`).
644
874
 
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}] 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
875
  ### Common issues
668
876
 
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).
877
+ **Guards not running**: Verify the route name passed to `addRouteGuard()` matches the route name in `manifest.json`, not the pattern or target name. Guards on redirect targets do run; if a redirect chain is blocked by loop detection, check the error log for details -- see [Redirect chains](#redirect-chains).
670
878
 
671
879
  **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
880
 
673
881
  **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
882
 
883
+ **Async guard hangs indefinitely**: `context.signal` only aborts on supersede or router stop/destroy, not on "too slow." If a guard's `fetch` targets a dead endpoint, the navigation stays in the evaluating phase forever. Combine `context.signal` with `AbortSignal.timeout()` to enforce a hard deadline: `signal: AbortSignal.any([context.signal, AbortSignal.timeout(10_000)])`. See the [async guard timeout pattern](../../docs/guides/integration-patterns.md#async-guard-timeout) for the full example and a compatibility fallback for older browsers.
884
+
675
885
  **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
886
 
677
887
  **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.
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "buildManifest": {
18
18
  "manifestVersion": "0.2",
19
- "timestamp": "2026-03-22T11:02:53.747Z",
19
+ "timestamp": "2026-03-23T12:27:04.576Z",
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.3.2",
34
+ "version": "1.5.0",
35
35
  "namespace": "ui5/guard/router",
36
36
  "tags": {
37
37
  "/resources/ui5/guard/router/GuardPipeline-dbg.js": {
package/dist/index.d.ts CHANGED
@@ -33,7 +33,7 @@
33
33
  // - @types/istanbul-lib-coverage@2.0.6
34
34
  // - @types/qunit@2.19.13
35
35
  /// <reference path="./resources/ui5/guard/router/NavigationOutcome.d.ts"/>
36
- /// <reference path="./resources/ui5/guard/router/types.d.ts"/>
37
- /// <reference path="./resources/ui5/guard/router/Router.d.ts"/>
38
36
  /// <reference path="./resources/ui5/guard/router/library.d.ts"/>
39
- /// <reference path="./resources/ui5/guard/router/GuardPipeline.d.ts"/>
37
+ /// <reference path="./resources/ui5/guard/router/Router.d.ts"/>
38
+ /// <reference path="./resources/ui5/guard/router/GuardPipeline.d.ts"/>
39
+ /// <reference path="./resources/ui5/guard/router/types.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.3.2</version>
5
+ <version>1.5.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>
@@ -43,24 +43,56 @@ sap.ui.define(["sap/base/Log"], function (Log) {
43
43
  _globalGuards = [];
44
44
  _enterGuards = new Map();
45
45
  _leaveGuards = new Map();
46
+
47
+ /** Register a guard that runs for every navigation. */
46
48
  addGlobalGuard(guard) {
47
49
  this._globalGuards.push(guard);
48
50
  }
51
+
52
+ /** Remove a previously registered global guard by reference. */
49
53
  removeGlobalGuard(guard) {
50
54
  const index = this._globalGuards.indexOf(guard);
51
55
  if (index !== -1) {
52
56
  this._globalGuards.splice(index, 1);
53
57
  }
54
58
  }
59
+
60
+ /**
61
+ * Register an enter guard for a specific route.
62
+ *
63
+ * @param route - Route name as defined in `manifest.json`.
64
+ * @param guard - Guard function to register.
65
+ */
55
66
  addEnterGuard(route, guard) {
56
67
  this._addToGuardMap(this._enterGuards, route, guard);
57
68
  }
69
+
70
+ /**
71
+ * Remove a previously registered enter guard by reference.
72
+ *
73
+ * @param route - Route name.
74
+ * @param guard - Guard function to remove.
75
+ */
58
76
  removeEnterGuard(route, guard) {
59
77
  this._removeFromGuardMap(this._enterGuards, route, guard);
60
78
  }
79
+
80
+ /**
81
+ * Register a leave guard for a specific route.
82
+ *
83
+ * @param route - Route name as defined in `manifest.json`.
84
+ * @param guard - Leave guard function to register.
85
+ */
61
86
  addLeaveGuard(route, guard) {
62
87
  this._addToGuardMap(this._leaveGuards, route, guard);
63
88
  }
89
+
90
+ /**
91
+ * Remove a previously registered leave guard by reference.
92
+ *
93
+ * @param route - Route name.
94
+ * @param guard - Leave guard function to remove.
95
+ */
64
96
  removeLeaveGuard(route, guard) {
65
97
  this._removeFromGuardMap(this._leaveGuards, route, guard);
66
98
  }
@@ -81,9 +113,15 @@ sap.ui.define(["sap/base/Log"], function (Log) {
81
113
  *
82
114
  * @param context - Complete guard context including AbortSignal.
83
115
  * `context.fromRoute` controls leave-guard lookup: empty string skips leave guards.
116
+ * @param options - Optional evaluation options.
117
+ * @param options.skipLeaveGuards - When true, leave guards are skipped even if
118
+ * `context.fromRoute` is set. Used by redirect chain hops to avoid re-running
119
+ * leave guards while still preserving `fromRoute` in the context.
120
+ * @returns A synchronous {@link GuardDecision} when all guards return plain values,
121
+ * or a `Promise<GuardDecision>` when at least one guard returns a thenable.
84
122
  */
85
- evaluate(context) {
86
- const hasLeaveGuards = context.fromRoute !== "" && this._leaveGuards.has(context.fromRoute);
123
+ evaluate(context, options) {
124
+ const hasLeaveGuards = !options?.skipLeaveGuards && context.fromRoute !== "" && this._leaveGuards.has(context.fromRoute);
87
125
  const hasEnterGuards = this._globalGuards.length > 0 || context.toRoute !== "" && this._enterGuards.has(context.toRoute);
88
126
  if (!hasLeaveGuards && !hasEnterGuards) {
89
127
  return {
@@ -103,6 +141,11 @@ sap.ui.define(["sap/base/Log"], function (Log) {
103
141
  action: "redirect",
104
142
  target: r
105
143
  };
144
+ }).catch(error => {
145
+ return {
146
+ action: "error",
147
+ error
148
+ };
106
149
  });
107
150
  }
108
151
  if (enterResult === true) return {
@@ -120,24 +163,36 @@ sap.ui.define(["sap/base/Log"], function (Log) {
120
163
  const enterResult = this._runEnterGuards(context.toRoute, context);
121
164
  return processEnterResult(enterResult);
122
165
  };
123
- if (hasLeaveGuards) {
124
- const leaveResult = this._runLeaveGuards(context);
125
- if (isPromiseLike(leaveResult)) {
126
- return leaveResult.then(allowed => {
127
- if (allowed !== true) return {
128
- action: "block"
129
- };
130
- if (context.signal.aborted) return {
131
- action: "block"
132
- };
133
- return runEnterPhase();
134
- });
166
+ try {
167
+ if (hasLeaveGuards) {
168
+ const leaveResult = this._runLeaveGuards(context);
169
+ if (isPromiseLike(leaveResult)) {
170
+ return leaveResult.then(allowed => {
171
+ if (allowed !== true) return {
172
+ action: "block"
173
+ };
174
+ if (context.signal.aborted) return {
175
+ action: "block"
176
+ };
177
+ return runEnterPhase();
178
+ }).catch(error => {
179
+ return {
180
+ action: "error",
181
+ error
182
+ };
183
+ });
184
+ }
185
+ if (leaveResult !== true) return {
186
+ action: "block"
187
+ };
135
188
  }
136
- if (leaveResult !== true) return {
137
- action: "block"
189
+ return runEnterPhase();
190
+ } catch (error) {
191
+ return {
192
+ action: "error",
193
+ error
138
194
  };
139
195
  }
140
- return runEnterPhase();
141
196
  }
142
197
  _addToGuardMap(map, key, guard) {
143
198
  let guards = map.get(key);
@@ -174,8 +229,9 @@ sap.ui.define(["sap/base/Log"], function (Log) {
174
229
  }
175
230
  if (result !== true) return this._validateLeaveGuardResult(result);
176
231
  } catch (error) {
177
- Log.error(`Leave guard [${i}] on route "${context.fromRoute}" threw, blocking navigation`, String(error), LOG_COMPONENT);
178
- return false;
232
+ Log.error(`Leave guard [${i}] on route "${context.fromRoute}" threw, navigation failed`, String(error), LOG_COMPONENT);
233
+ if (context.signal.aborted) return false;
234
+ throw error;
179
235
  }
180
236
  }
181
237
  return true;
@@ -218,8 +274,9 @@ sap.ui.define(["sap/base/Log"], function (Log) {
218
274
  }
219
275
  if (result !== true) return this._validateGuardResult(result);
220
276
  } catch (error) {
221
- Log.error(`Enter guard [${i}] on route "${context.toRoute}" threw, blocking navigation`, String(error), LOG_COMPONENT);
222
- return false;
277
+ Log.error(`Enter guard [${i}] on route "${context.toRoute}" threw, navigation failed`, String(error), LOG_COMPONENT);
278
+ if (context.signal.aborted) return false;
279
+ throw error;
223
280
  }
224
281
  }
225
282
  return true;
@@ -252,7 +309,8 @@ sap.ui.define(["sap/base/Log"], function (Log) {
252
309
  } catch (error) {
253
310
  if (!context.signal.aborted) {
254
311
  const route = isLeaveGuard ? context.fromRoute : context.toRoute;
255
- Log.error(`${label} [${guardIndex}] on route "${route}" threw, blocking navigation`, String(error), LOG_COMPONENT);
312
+ Log.error(`${label} [${guardIndex}] on route "${route}" threw, navigation failed`, String(error), LOG_COMPONENT);
313
+ throw error;
256
314
  }
257
315
  return false;
258
316
  }