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.
- package/README.md +260 -50
- package/dist/.ui5/build-manifest.json +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/resources/ui5/guard/router/.library +1 -1
- package/dist/resources/ui5/guard/router/GuardPipeline-dbg.js +80 -22
- package/dist/resources/ui5/guard/router/GuardPipeline-dbg.js.map +1 -1
- package/dist/resources/ui5/guard/router/GuardPipeline.d.ts +38 -1
- package/dist/resources/ui5/guard/router/GuardPipeline.d.ts.map +1 -1
- package/dist/resources/ui5/guard/router/GuardPipeline.js +1 -1
- package/dist/resources/ui5/guard/router/GuardPipeline.js.map +1 -1
- package/dist/resources/ui5/guard/router/NavigationOutcome-dbg.js +3 -1
- package/dist/resources/ui5/guard/router/NavigationOutcome-dbg.js.map +1 -1
- package/dist/resources/ui5/guard/router/NavigationOutcome.d.ts +2 -0
- package/dist/resources/ui5/guard/router/NavigationOutcome.d.ts.map +1 -1
- package/dist/resources/ui5/guard/router/NavigationOutcome.js +1 -1
- package/dist/resources/ui5/guard/router/NavigationOutcome.js.map +1 -1
- package/dist/resources/ui5/guard/router/Router-dbg.js +755 -76
- package/dist/resources/ui5/guard/router/Router-dbg.js.map +1 -1
- package/dist/resources/ui5/guard/router/Router.d.ts +184 -8
- package/dist/resources/ui5/guard/router/Router.d.ts.map +1 -1
- package/dist/resources/ui5/guard/router/Router.js +1 -1
- package/dist/resources/ui5/guard/router/Router.js.map +1 -1
- package/dist/resources/ui5/guard/router/library-dbg.js +1 -1
- package/dist/resources/ui5/guard/router/library-dbg.js.map +1 -1
- package/dist/resources/ui5/guard/router/library-preload.js +5 -5
- package/dist/resources/ui5/guard/router/library-preload.js.map +1 -1
- package/dist/resources/ui5/guard/router/library.js +1 -1
- package/dist/resources/ui5/guard/router/library.js.map +1 -1
- package/dist/resources/ui5/guard/router/manifest.json +1 -1
- package/dist/resources/ui5/guard/router/types-dbg.js.map +1 -1
- package/dist/resources/ui5/guard/router/types.d.ts +95 -3
- package/dist/resources/ui5/guard/router/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/GuardPipeline.ts +74 -25
- package/src/NavigationOutcome.ts +2 -0
- package/src/Router.ts +922 -72
- package/src/manifest.json +1 -1
- 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
|
|
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
|
-
##
|
|
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
|
|
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
|
|
552
|
-
→
|
|
785
|
+
→ profile guard checks onboarding status ← this guard RUNS
|
|
786
|
+
→ onboarding guard allows → onboarding view renders
|
|
553
787
|
```
|
|
554
788
|
|
|
555
|
-
|
|
789
|
+
Two safeguards prevent infinite redirect loops:
|
|
556
790
|
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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/
|
|
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.
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
}
|