@keenmate/svelte-spa-router 5.2.0-rc01 → 5.2.0-rc02
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/CHANGELOG.md +87 -0
- package/README.md +204 -27
- package/package.json +7 -2
- package/src/lib/Router.svelte +167 -30
- package/src/lib/helpers/GlobalErrorHandler.svelte +0 -124
- package/src/lib/helpers/error-handler.d.ts +0 -6
- package/src/lib/helpers/error-handler.svelte.js +0 -1
- package/src/lib/helpers/hierarchy.d.ts +7 -1
- package/src/lib/helpers/permissions.d.ts +92 -9
- package/src/lib/helpers/permissions.svelte.js +110 -13
- package/src/lib/helpers/route-metadata.svelte.js +43 -1
- package/src/lib/helpers/url-helpers.svelte.js +23 -0
- package/src/lib/index.js +13 -10
- package/src/lib/routes.d.ts +5 -0
- package/src/lib/routes.svelte.js +4 -9
- package/src/lib/utils.d.ts +29 -0
- package/src/lib/utils.svelte.js +124 -7
- package/src/lib/wrap.d.ts +28 -2
- package/src/lib/wrap.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,93 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.2.0-rc02] - 2026-05-01
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`ReferenceError: __PACKAGE_NAME__ is not defined` masking real route errors** — The global API setup in `src/lib/index.js` referenced bundler-injected placeholders (`__PACKAGE_NAME__`, `__VERSION__`, `__AUTHOR__`, `__LICENSE__`, `__REPOSITORY__`, `__HOMEPAGE__`) with `typeof X !== 'undefined'` fallbacks. Consumer Vite optimizers (esbuild prebundle) were not preserving the guard, leaving bare references that threw `ReferenceError` at module-init. The error surfaced asynchronously during route activation and masked real errors thrown by route components.
|
|
12
|
+
- Replaced the placeholder pattern with a direct `import pkg from '../../package.json'` — works universally because npm always ships `package.json` and all modern bundlers handle JSON imports natively.
|
|
13
|
+
- No consumer-side bundler config required.
|
|
14
|
+
|
|
15
|
+
- **Svelte 5 `state_referenced_locally` warnings on every consumer dev build** — `Router.svelte` parsed the `routes` prop into `routesList` at script top level, which captures only the initial value. Svelte 5 emitted three `state_referenced_locally` warnings (lines 172, 173, 177) in any consumer's dev console, and silently ignored prop swaps if a consumer ever replaced `routes`.
|
|
16
|
+
- Wrapped the parse logic in `$derived.by(() => { ... })` so the closure captures `routes` reactively. Warnings are gone and the routes prop is now properly reactive — replacing it rebuilds `routesList` and takes effect on the next navigation.
|
|
17
|
+
- `findParentRoute()` and `findMatchingRoute()` already read `routesList` per-call, so no other changes were needed.
|
|
18
|
+
|
|
19
|
+
- **`navigationContext()` was never `null` after any `push()` call, breaking the "no context" check in consumer code** — `push()` and `replace()` internally inject `_routeName` (for referrer tracking) and optionally `__scrollBehavior` (for scroll control) into the stored navigation context. Previously these internal keys leaked through the public `navigationContext()` accessor, so `navigationContext()` returned `{ _routeName: '/path' }` (truthy) instead of `null` even when the consumer hadn't passed any context. Code patterns like `{#if !navigationContext()}` or `if (ctx === null)` silently broke. The `NavigationContextDemo` example was a victim: clicking "Back to List" called `push('/navigation-context-demo')`, expecting `ctx` to become null and the list view to re-render — but `ctx` was `{ _routeName: ... }`, so none of the `{#if}` branches matched and the middle area went blank.
|
|
20
|
+
- `navigationContext()` now filters out the router's internal keys (`_routeName`, `__scrollBehavior`) and returns `null` if nothing user-visible remains.
|
|
21
|
+
- Added an internal `getRawNavigationContext()` accessor that returns the full state including internal keys. `Router.svelte` uses this for its own internal reads (referrer-route lookup, scroll-behavior override). Not exposed in the public type declarations.
|
|
22
|
+
- **Migration:** if you were depending on seeing `_routeName` or `__scrollBehavior` in `navigationContext()` output (you almost certainly weren't), use `getRawNavigationContext()` from utils — but these are internal flags and the contract is that they may change without notice.
|
|
23
|
+
- vitest coverage in `navigation.test.js`: two new cases assert `navigationContext()` returns `null` after a no-context `push()`, and that user keys pass through while `_routeName` is filtered.
|
|
24
|
+
|
|
25
|
+
- **`push('/path', {}, queryObject)` and `replace(...)` silently dropped the querystring** — The multi-parameter signature `push(route, params, query, context)` only serialized the `query` object when the first argument was a *named route*. When the first argument was a path string (starting with `/`), the path branch stored `query` on `opts` but the downstream code only read `opts.href`, so the query object was never applied to the URL. The named-route branch worked because it routes through `buildUrl()`, which does the serialization. Existing tests only covered the named-route case and the path case with empty `{}`, so the regression was invisible.
|
|
26
|
+
- Extracted `serializeQuery(query)` into `helpers/url-helpers.svelte.js` and reuse it in both `buildUrl()` (named branch, behavior unchanged) and `push()` / `replace()` (path branch).
|
|
27
|
+
- When the path already contains a `?`, the serialized query is appended with `&`; otherwise with `?`.
|
|
28
|
+
- Added 6 vitest cases covering: path + query object, URL encoding of keys/values, skipping `null` / `undefined`, merging with a path-embedded query, empty-query no-op, and the equivalent `replace()` case.
|
|
29
|
+
|
|
30
|
+
- **`onNotFound` was never emitted when a `'*'` catch-all route was configured** — `runRoutingPipeline()` only dispatched `notFound` on the true no-match path (`!ctx.match`). With a `'*'` route present, `regexparam` turned it into a wildcard that matched every unmatched URL, so `ctx.match` was always truthy and the event was suppressed. Apps that configured a `'*': NotFound` route to render a 404 page lost the ability to *observe* 404s programmatically (for logging, analytics, error tracking).
|
|
31
|
+
- In `src/lib/Router.svelte`, after the no-match early-exit, dispatch `notFound` whenever `ctx.match.routeItem.path === '*'`. The catch-all component still renders — the event is purely additive.
|
|
32
|
+
- The check uses the same exact-`'*'` test as the existing `isCatchAll` flag at line 727, so subtree wildcards like `'/admin/*'` (whose `routeItem.path` is `/admin/*`, not `*`) and the synthetic unauthorized-route match (whose `routeItem.path` is the unauthorized route) are unaffected.
|
|
33
|
+
- Added an e2e test in `e2e/router-events.spec.ts` asserting that both the catch-all component renders *and* `onNotFound` fires with the correct `{ location, querystring }` payload. The pre-existing nested-router-without-catch-all test still covers the true no-match path.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- **`revalidateCurrentRoute()` + `onRevalidationFailure` — re-check the active route on user state changes** — `hasPermission()` reactivity (above) covers UI element visibility (menus, buttons), but it doesn't cover the case where the user is *sitting on a protected page* when their permissions are revoked. The router checks route conditions only during navigation, so a user already on `/admin` who loses admin permission would stay on `/admin` until they navigated away. This is exactly the websocket-permission-update scenario most real apps need to handle.
|
|
37
|
+
- New `revalidateCurrentRoute()` export from the main module. Re-runs guards and conditions against the currently mounted route without re-mounting the component. On success, nothing visible happens — the component keeps its state (no flicker, no scroll reset, no in-flight form data lost). On failure, the same unauthorized handling that runs for fresh navigation fires here too.
|
|
38
|
+
- New `configurePermissions({ onRevalidationFailure })` callback. When configured, fires *instead of* the standard unauthorized handling for revalidation failures — letting apps show a confirmation dialog, soft-warn the user, or log an audit trail before deciding what to do. If the callback doesn't navigate, the user stays on the current page. The `onConditionsFailed` Router event still fires for consistency with navigation behavior. Pass `null` to clear and fall back to standard handling.
|
|
39
|
+
- Calls to `revalidateCurrentRoute()` within a ~50ms window are coalesced into a single re-validation pass — safe to call on every websocket message.
|
|
40
|
+
- Multiple Router instances (nested routers, zones) each register independently and re-validate their own routes.
|
|
41
|
+
- Implementation: `runRoutingPipeline` accepts a `revalidationOnly` option that (a) skips the `beforeLeave` guards (user isn't navigating away), (b) skips the `routeLoading` event dispatch (nothing's loading), (c) on success returns early before the load/commit phases (preserves the mounted component), (d) on failure routes through the new callback when configured. The existing `loadingId` race-condition mechanism handles overlapping navigation/revalidation cleanly.
|
|
42
|
+
- vitest coverage in `navigation.test.js` (5 new cases: debounce, coalescing, multi-listener, unregister, throwing-listener tolerance) and `permissions.test.js` (4 new cases for the handler getter/setter contract).
|
|
43
|
+
- e2e: new `RevalidateTest` + `RevalidateProtected` fixtures and `e2e/revalidate.spec.ts` (3 tests: callback fires on protected-route downgrade, no callback when still authorized, no-op on unprotected routes). App.svelte wires up an `onRevalidationFailure` recorder for the spec to assert on.
|
|
44
|
+
- **Bug fix found while wiring this up:** the pipeline's internal `isPermissionFailure` value is the permissions object (truthy) rather than the literal `true`. Coerced to a boolean before exposing it in the callback's `detail`.
|
|
45
|
+
|
|
46
|
+
- **`setCurrentUser()` / `getCurrentUser()` — reactivity-by-default for `hasPermission()`** — Previously, `hasPermission()` only re-evaluated in `{#if}` blocks if the consumer's configured `getCurrentUser` happened to read reactive state. The README's canonical example used `getCurrentUser: () => get(currentUser)` (Svelte 4 store, non-reactive read), so following the docs literally meant `{#if hasPermission(...)}` only updated on navigation. A websocket pushing a permission change wouldn't update the UI until the user clicked a link.
|
|
47
|
+
- Added module-level `$state` (`currentUserState`) inside `permissions.svelte.js`. The default `currentUserGetter` now reads from this rune, so any `hasPermission()` call inside a reactive context (`{#if}`, `$derived`, `$effect`) automatically tracks user changes.
|
|
48
|
+
- New `setCurrentUser(user)` export — consumers call this on login/logout/websocket update; every reactive `hasPermission()` call site re-evaluates immediately. No subscription wiring needed on the consumer side.
|
|
49
|
+
- New `getCurrentUser()` export — symmetric reader for cases like `setCurrentUser({ ...getCurrentUser(), permissions: newPerms })`.
|
|
50
|
+
- `configurePermissions({ getCurrentUser })` still works for consumers who already maintain their own reactive user store (and remains the right choice when they do). Pass `getCurrentUser: null` to explicitly reset back to the default state-backed getter.
|
|
51
|
+
- **Migration:** existing apps keep working unchanged. New apps can skip `getCurrentUser` and use `setCurrentUser` instead — typically less code and guaranteed-reactive without thinking about it. The README, `permissions.d.ts`, and `ai/permissions.txt` now recommend this path and call out the non-reactive footgun explicitly.
|
|
52
|
+
- vitest coverage (`src/tests/permissions.test.js`): 5 new cases — `setCurrentUser`/`getCurrentUser` round-trip, `hasPermission` reflecting writes, explicit `getCurrentUser` overriding the default, `getCurrentUser: null` resetting back, and logged-out (`null`) handling.
|
|
53
|
+
|
|
54
|
+
- **Diagnostic warning when `shouldDisplayLoadingOnRouteLoad` routes never call `hideLoading()`** — Routes configured with `wrap({ shouldDisplayLoadingOnRouteLoad: true, loadingComponent: ... })` mount the real component immediately but keep it hidden under the loading component, waiting for the component itself to call `hideLoading()` from `@keenmate/svelte-spa-router/helpers/route-metadata` once data is ready. Previously, if the consumer forgot to call `hideLoading()` (or threw before reaching it, or hit a code path that didn't call it), the loading screen stayed up forever — no console message, no timeout, no recovery short of navigating away. The author hit this themselves while writing the e2e fixture and spent ten minutes debugging a "broken" wrap option.
|
|
55
|
+
- `startRouteLoading()` in `route-metadata.svelte.js` now schedules a `setTimeout(console.warn, 10_000)` that fires only if `isRouteLoading` is still true at the threshold. Bypasses the configurable logger (matches the project convention that misconfiguration warnings always print). The message names the option, names the required call, and links to the README.
|
|
56
|
+
- `hideLoading()` and a subsequent `startRouteLoading()` both clear the timer so the warning never fires when things are working normally.
|
|
57
|
+
- vitest coverage in `route-metadata.test.js`: three new cases (warning fires after threshold, warning doesn't fire when `hideLoading()` is called in time, warning timer resets on subsequent `startRouteLoading()`).
|
|
58
|
+
|
|
59
|
+
- **Pending `waitForRouteReady()` promises are resolved when a new navigation starts** — Previously, if a user navigated away from a `shouldDisplayLoadingOnRouteLoad` route before its `hideLoading()` fired, the next `startRouteLoading()` clobbered `routeReadyResolvers = []` without resolving the pending entry. The old pipeline's `await waitForRouteReady()` was orphaned and leaked one unresolvable promise per abandoned navigation. After resolving, it would also have raced with the new pipeline's state writes if not for the new race check.
|
|
60
|
+
- `startRouteLoading()` now resolves any pending resolvers before clearing the array.
|
|
61
|
+
- `Router.svelte` adds a `loadingId !== loadingId` race-condition check immediately after `await waitForRouteReady()`, matching the pattern already used after `pipelineLoadComponent` and `pipelineLoadZoneComponents`. Stale pipeline runs bail cleanly instead of racing.
|
|
62
|
+
- vitest coverage: new case asserting that a second `startRouteLoading()` resolves the first navigation's pending waiter.
|
|
63
|
+
|
|
64
|
+
- **Documented the `shouldDisplayLoadingOnRouteLoad` contract loudly** — `wrap.d.ts`, `wrap.js`, `routes.d.ts`, `helpers/hierarchy.d.ts`, and the README's Pattern 1 section now spell out the "you MUST call `hideLoading()`" requirement and the blank-page failure mode. Old docstring was 14 words; new copy names the failure mode and the dev-mode safety net.
|
|
65
|
+
|
|
66
|
+
- **`relativeLocation` field on every Router event payload** — Nested Routers configured with `prefix="/foo/bar"` define their routes in prefix-relative terms (e.g. `'/known'`, not `'/foo/bar/known'`) and match against the prefix-stripped path internally. But every event payload (`onRouteLoading`, `onRouteLoaded`, `onConditionsFailed`, `onNotFound`) reported the **full app URL** in `location` — so the router used two different notions of "location" depending on whether you looked at route definitions or event payloads. Consumers had to know the difference and strip the prefix themselves to reason about what the nested Router actually saw.
|
|
67
|
+
- Added a new `relativeLocation` field to every dispatched event payload that already carried `location`. For a root Router (no `prefix`), `relativeLocation === location`. For a nested Router with `prefix`, it's the path with the prefix stripped (`/` if stripping leaves it empty). For paths outside the prefix (the rare case where the parent app navigates to something the nested Router doesn't own), `relativeLocation` equals `location`.
|
|
68
|
+
- `location` is preserved unchanged — useful for logging, analytics, and re-navigating with `push()` (which always takes app-wide paths). `relativeLocation` is useful for reasoning about *this* Router's routing decisions.
|
|
69
|
+
- All 7 `dispatchNextTick` sites in `Router.svelte` updated. The field is computed once per pipeline run in `createPipelineContext` and threaded through `ctx`.
|
|
70
|
+
- README event-payload table updated to list the field and explain when it differs from `location`.
|
|
71
|
+
- e2e coverage: extended `e2e/router-events.spec.ts` to assert (1) `relativeLocation === '/missing'` for a nested Router with `prefix="/test/embed"` navigating to `/test/embed/missing`, and (2) `relativeLocation === location` for the root Router with no prefix.
|
|
72
|
+
|
|
73
|
+
### Changed (docs)
|
|
74
|
+
- **Documented `conditions` failure behavior and the `conditions` vs `permissions` distinction** — Previously the README showed how to *write* a `wrap({ conditions: [...] })` guard and listed `onConditionsFailed` as a Router event, but never explained what the user actually sees when a condition returns `false`: the route component is unmounted, the slot becomes empty, and no built-in fallback UI is rendered. This surprised even the maintainers when writing the e2e suite — assertions assumed conditions failures would mount the same Unauthorized component that the permission system mounts.
|
|
75
|
+
- `README.md`: added a "What happens when a condition returns `false`" callout to the *Route guards (pre-conditions)* section; expanded the *Event handling* example with a payload-shape table (`onRouteLoading` / `onRouteLoaded` / `onConditionsFailed` / `onNotFound`); added a new *Conditions vs Permissions* subsection with a side-by-side comparison covering setup, where the logic lives, what happens on failure (UI + event), and when to choose each.
|
|
76
|
+
- `ai/guards-conditions.txt`: added a `WHAT HAPPENS ON FAILURE` section mirroring the README so the AI-facing summary captures the same fact.
|
|
77
|
+
- **No code change.** The conditions/permissions split is the intended design — conditions are the low-level primitive, permissions are the opinionated wrapper that builds on top. Per the project's "library shouldn't paint default UI" principle (see the toast removal in this same release), conditions stay unopinionated. The behavior just needed to be documented.
|
|
78
|
+
|
|
79
|
+
### Removed (breaking — rc02 is unreleased)
|
|
80
|
+
- **Built-in error toast removed from `GlobalErrorHandler`** — The library used to render its own `<div class="error-toast">` on caught errors, gated by the `showToast` config flag (`true` by default). The render condition was `toastVisible && errorState.currentError && !config.showErrorComponent`, which made it dead code under the default `navigateSafe` strategy: that strategy calls `clearError()` synchronously after `push()` in the same handler tick, so by the time Svelte's reactive system flushed the `toastVisible = true` update, `errorState.currentError` was already `null` and the toast never rendered. `restart` with `autoRestart: false` and `showError` set `config.showErrorComponent = true`, which the same guard hides — so the toast was effectively only observable for `restart` with `autoRestart: true` during the delay window, and for `custom` strategies that left state untouched.
|
|
81
|
+
- Rather than patch the broken interaction, the toast is removed entirely. Notification UI is the consumer's responsibility — every app already has a preferred toast/snackbar library, and the router's job is to surface the event, not paint pixels.
|
|
82
|
+
- `onError(error, errorInfo, context)` is the supported integration point and was already wired up. Consumers can call their own toast library, Sentry, LogRocket, analytics, etc. from inside that callback.
|
|
83
|
+
- Removed: `showToast` config field (was: `boolean`, default `true`), `toastVisible` / `toastTimeoutId` state, `showToast()` / `dismissToast()` helpers, the toast `{#if}` block in the template, and all `.error-toast` / `.toast-*` CSS in `GlobalErrorHandler.svelte`.
|
|
84
|
+
- Updated: the `GlobalErrorHandlerConfig` TypeScript declaration (`error-handler.d.ts`), the example app (`main.js`, `ErrorHandlingDemo.svelte`, `test/ErrorTest.svelte`), and documentation (`README.md`, `ai/error-handling.txt`, `CLAUDE.md`, `e2e/README.md`, `e2e/error-handling.spec.ts`) to drop `showToast` and point at `onError` as the toast hook.
|
|
85
|
+
- **Migration:** if you were passing `showToast: true`, remove it (TypeScript will flag it). To preserve toast behavior, call your toast library inside `onError` — e.g. `onError: (error) => toast.error(error.message)`.
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
- **Playwright e2e test suite** — Browser-level tests for the router, run against the example app in history mode on port 5050. Dedicated, minimal fixture pages live under `example/src/routes/test/` and are kept separate from the demo routes (which are user-oriented and evolve over time) so assertions stay stable.
|
|
89
|
+
- **16 specs / 98 tests** covering all 20 feature areas from `ai/INDEX.txt`: basic setup & events, navigation (push/replace/pop/goBack + array/object signatures), named routes, route params (required / optional / wildcard), permissions (RBAC + authorization), guards & conditions, hierarchical inheritance, tree structure (`createHierarchy`), link actions (`use:link`, `use:active`), error handling (GlobalErrorHandler), referrer tracking, breadcrumbs / route metadata, debug logging & `window.components` API, querystring helpers, filters, multi-zone, loading states, 404 / NotFound, `wrap()`, and `routeContext`.
|
|
90
|
+
- Playwright auto-starts the example dev server and reuses an already-running one (so `make dev` in another terminal is fine).
|
|
91
|
+
- Each fixture surfaces router state via `data-testid` nodes; action buttons use `data-testid="btn-<action>"`. Reload always resets fixture state.
|
|
92
|
+
- New npm scripts: `test:e2e`, `test:e2e:install`, `test:e2e:ui`, `test:e2e:headed`. New Makefile targets: `test-e2e`, `test-e2e-install`, `test-e2e-ui`.
|
|
93
|
+
- Documentation: `e2e/README.md` covers run instructions, the fixture/spec convention, a coverage matrix, and a 6-step recipe for adding new fixtures. A discoverable in-browser index of all fixtures lives at `/test`.
|
|
94
|
+
|
|
8
95
|
## [5.2.0-rc01] - 2026-02-18
|
|
9
96
|
|
|
10
97
|
### Fixed
|
package/README.md
CHANGED
|
@@ -28,6 +28,28 @@ Main features:
|
|
|
28
28
|
|
|
29
29
|
This module is released under MIT license.
|
|
30
30
|
|
|
31
|
+
## What's new
|
|
32
|
+
|
|
33
|
+
### v5.2.0-rc02
|
|
34
|
+
|
|
35
|
+
- **Playwright e2e suite** — 16 specs / 103 tests covering every feature area, with dedicated browser-level fixtures in `example/src/routes/test/`
|
|
36
|
+
- **`setCurrentUser()` + reactivity-by-default** — `hasPermission()` updates live in `{#if}` blocks without subscription wiring; no more `get(store)` footgun
|
|
37
|
+
- **`revalidateCurrentRoute()` + `onRevalidationFailure`** — re-check the currently mounted route on out-of-band user changes (websocket permission updates, token refresh) without remounting on success
|
|
38
|
+
- **`relativeLocation` on every Router event payload** — prefix-stripped view for nested routers; `location` stays full URL
|
|
39
|
+
- **Several router fixes** — `push('/path', {}, queryObj)` was dropping the query; `onNotFound` never fired when `'*'` catch-all was configured; `navigationContext()` was leaking the internal `_routeName` key (so `!ctx` was never true)
|
|
40
|
+
- **Diagnostic warning** when `shouldDisplayLoadingOnRouteLoad` routes never call `hideLoading()` — surfaces forgotten-callback bugs after 10s instead of blank pages forever
|
|
41
|
+
- **Built-in toast removed from `GlobalErrorHandler`** (breaking — rc02 unreleased) — notification UI belongs in your stack via the `onError` callback
|
|
42
|
+
|
|
43
|
+
### v5.2.0-rc01
|
|
44
|
+
|
|
45
|
+
- **`defineRoutes()`** — type-safe route definitions with IDE autocomplete on route names and params, plus generated `nav.X.push(params)` and `paths.X(params)` helpers
|
|
46
|
+
- **Comprehensive test suite** — expanded from ~156 to 347 passing vitest tests across 16 files (0 skipped)
|
|
47
|
+
- **AI-assistant documentation** — 15 plain-text reference files in `ai/` (basic-setup, navigation, permissions, …) optimized for Claude / Cursor / Copilot
|
|
48
|
+
- **Route Context Demo pages** — interactive examples for `routeContext()`, `routeTitle()`, `routeBreadcrumbs()`
|
|
49
|
+
- **Several TypeScript + correctness fixes** — `routeContext()` mangled name, `wrap()` not merging `title`/`breadcrumbs` into routeContext, missing type declarations for `GlobalErrorHandler` / `ErrorDisplay` / `setHierarchicalRoutesEnabled` / `setIncludeReferrer`
|
|
50
|
+
|
|
51
|
+
Full details in [CHANGELOG.md](./CHANGELOG.md).
|
|
52
|
+
|
|
31
53
|
## Installation
|
|
32
54
|
|
|
33
55
|
```sh
|
|
@@ -957,6 +979,16 @@ The router provides flexible loading control with support for three distinct pat
|
|
|
957
979
|
|
|
958
980
|
Use `loadingComponent` with `shouldDisplayLoadingOnRouteLoad: true` for multi-zone layouts where the Router manages the loading state:
|
|
959
981
|
|
|
982
|
+
> **⚠️ You must call `hideLoading()` from the route component.** When
|
|
983
|
+
> `shouldDisplayLoadingOnRouteLoad: true` is set, the router mounts the route
|
|
984
|
+
> component immediately but keeps it hidden under `loadingComponent` and waits
|
|
985
|
+
> for an explicit `hideLoading()` signal before revealing it. If the component
|
|
986
|
+
> never calls `hideLoading()` (forgotten, thrown before reaching it, conditional
|
|
987
|
+
> code path that didn't run), the loading screen stays up forever and the real
|
|
988
|
+
> component never appears — the route is effectively bricked until the user
|
|
989
|
+
> navigates away. In development, a `console.warn` fires after 10 seconds to
|
|
990
|
+
> surface this; production has no automatic recovery.
|
|
991
|
+
|
|
960
992
|
```javascript
|
|
961
993
|
import { createRoute } from '@keenmate/svelte-spa-router/wrap'
|
|
962
994
|
import { hideLoading } from '@keenmate/svelte-spa-router/helpers/route-metadata'
|
|
@@ -1234,6 +1266,25 @@ const routes = {
|
|
|
1234
1266
|
}
|
|
1235
1267
|
```
|
|
1236
1268
|
|
|
1269
|
+
**What happens when a condition returns `false`:**
|
|
1270
|
+
The route's component is not mounted — the slot becomes **empty**. There is no
|
|
1271
|
+
built-in fallback UI. The router fires the `onConditionsFailed` event (see
|
|
1272
|
+
[Event handling](#event-handling)) and that's it. The consumer decides what
|
|
1273
|
+
happens next, typically by either redirecting from inside the condition itself
|
|
1274
|
+
(`await push('/login'); return false`) or by handling the event globally:
|
|
1275
|
+
|
|
1276
|
+
```svelte
|
|
1277
|
+
<Router
|
|
1278
|
+
{routes}
|
|
1279
|
+
onConditionsFailed={(e) => push('/login')}
|
|
1280
|
+
/>
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
If you want batteries-included Unauthorized-component rendering, use the
|
|
1284
|
+
[permission system](#permission-based-routing) instead — see
|
|
1285
|
+
[Conditions vs Permissions](#conditions-vs-permissions) for when to reach for
|
|
1286
|
+
which.
|
|
1287
|
+
|
|
1237
1288
|
### Permission-based routing
|
|
1238
1289
|
|
|
1239
1290
|
svelte-spa-router-5 includes a flexible permission system for role-based access control:
|
|
@@ -1241,38 +1292,120 @@ svelte-spa-router-5 includes a flexible permission system for role-based access
|
|
|
1241
1292
|
**1. Configure the permission system (in main.js before mounting):**
|
|
1242
1293
|
|
|
1243
1294
|
```javascript
|
|
1244
|
-
import {
|
|
1245
|
-
|
|
1246
|
-
|
|
1295
|
+
import {
|
|
1296
|
+
configurePermissions,
|
|
1297
|
+
setCurrentUser
|
|
1298
|
+
} from '@keenmate/svelte-spa-router/helpers/permissions'
|
|
1247
1299
|
|
|
1248
1300
|
configurePermissions({
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
)
|
|
1258
|
-
}
|
|
1301
|
+
checkPermissions: (user, requirements) => {
|
|
1302
|
+
if (!user) return false
|
|
1303
|
+
if (!requirements) return true
|
|
1304
|
+
|
|
1305
|
+
// Check if user has any of the required permissions
|
|
1306
|
+
if (requirements.any) {
|
|
1307
|
+
return requirements.any.some(perm => user.permissions.includes(perm))
|
|
1308
|
+
}
|
|
1259
1309
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1310
|
+
// Check if user has all required permissions
|
|
1311
|
+
if (requirements.all) {
|
|
1312
|
+
return requirements.all.every(perm => user.permissions.includes(perm))
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return true
|
|
1316
|
+
},
|
|
1317
|
+
onUnauthorized: (detail) => {
|
|
1318
|
+
push('/unauthorized')
|
|
1265
1319
|
}
|
|
1320
|
+
})
|
|
1266
1321
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1322
|
+
// Push the current user into the permission system. The library keeps an
|
|
1323
|
+
// internal $state-backed user, so every hasPermission() call site in a
|
|
1324
|
+
// reactive context (templates, $derived, $effect) re-evaluates automatically
|
|
1325
|
+
// when you call setCurrentUser() again.
|
|
1326
|
+
setCurrentUser(null) // logged-out at startup
|
|
1327
|
+
|
|
1328
|
+
// Later, on login:
|
|
1329
|
+
// setCurrentUser({ id: 42, permissions: ['admin.read'] })
|
|
1330
|
+
// From a websocket permission update:
|
|
1331
|
+
// setCurrentUser({ ...getCurrentUser(), permissions: newPerms })
|
|
1332
|
+
// On logout:
|
|
1333
|
+
// setCurrentUser(null)
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
> **Reactivity:** `hasPermission()` re-evaluates automatically when you call
|
|
1337
|
+
> `setCurrentUser()` — the function reads from an internal `$state` rune, so
|
|
1338
|
+
> Svelte's tracker registers the dependency in any reactive context (template
|
|
1339
|
+
> `{#if}`, `$derived`, `$effect`). No subscription wiring needed on your side.
|
|
1340
|
+
>
|
|
1341
|
+
> If you maintain your own reactive user store and prefer to read from it
|
|
1342
|
+
> directly, pass `getCurrentUser` to `configurePermissions`:
|
|
1343
|
+
>
|
|
1344
|
+
> ```js
|
|
1345
|
+
> configurePermissions({ getCurrentUser: () => myUserState.user, ... })
|
|
1346
|
+
> ```
|
|
1347
|
+
>
|
|
1348
|
+
> Watch out for non-tracked reads (`get(store)`, `localStorage.getItem`, etc.) —
|
|
1349
|
+
> those won't propagate updates, and your `{#if hasPermission(...)}` blocks
|
|
1350
|
+
> will appear "broken" (only updating on navigation). The default
|
|
1351
|
+
> `setCurrentUser`-based path avoids this footgun entirely.
|
|
1352
|
+
|
|
1353
|
+
**Re-validating the currently mounted route**
|
|
1354
|
+
|
|
1355
|
+
`hasPermission()` reactivity covers UI element visibility — the user's menu
|
|
1356
|
+
and buttons update live when permissions change. It does **not** cover the
|
|
1357
|
+
case where the user is *sitting on a protected page* when their permissions
|
|
1358
|
+
are revoked. The router checks route conditions only during navigation, so a
|
|
1359
|
+
user already on `/admin` who loses admin permission stays on `/admin` until
|
|
1360
|
+
they navigate away.
|
|
1361
|
+
|
|
1362
|
+
To handle this case, call `revalidateCurrentRoute()` after the permission
|
|
1363
|
+
change:
|
|
1364
|
+
|
|
1365
|
+
```javascript
|
|
1366
|
+
import { revalidateCurrentRoute } from '@keenmate/svelte-spa-router'
|
|
1367
|
+
import { setCurrentUser, getCurrentUser } from '@keenmate/svelte-spa-router/helpers/permissions'
|
|
1368
|
+
|
|
1369
|
+
socket.on('permissions:updated', (newPerms) => {
|
|
1370
|
+
setCurrentUser({ ...getCurrentUser(), permissions: newPerms })
|
|
1371
|
+
revalidateCurrentRoute()
|
|
1273
1372
|
})
|
|
1274
1373
|
```
|
|
1275
1374
|
|
|
1375
|
+
This re-runs the matched route's guards and conditions against the current
|
|
1376
|
+
location. On success, nothing visible happens — the component keeps its
|
|
1377
|
+
state (no flicker, no scroll reset, no in-flight form data lost). On
|
|
1378
|
+
failure, the same unauthorized handling that runs for fresh navigation
|
|
1379
|
+
fires here too.
|
|
1380
|
+
|
|
1381
|
+
If you want to customize the failure path — e.g. show a confirmation dialog
|
|
1382
|
+
before redirecting, soft-warn the user, log to an audit trail — provide an
|
|
1383
|
+
`onRevalidationFailure` handler:
|
|
1384
|
+
|
|
1385
|
+
```javascript
|
|
1386
|
+
configurePermissions({
|
|
1387
|
+
// ... checkPermissions, etc.
|
|
1388
|
+
onRevalidationFailure: async (detail) => {
|
|
1389
|
+
const confirmed = await showConfirmDialog(
|
|
1390
|
+
'Your permissions have changed. Return to the home page?'
|
|
1391
|
+
)
|
|
1392
|
+
if (confirmed) {
|
|
1393
|
+
push('/')
|
|
1394
|
+
}
|
|
1395
|
+
// If the user dismisses the dialog, they stay on the current page.
|
|
1396
|
+
}
|
|
1397
|
+
})
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
When `onRevalidationFailure` is configured, it fires **instead of** the
|
|
1401
|
+
standard unauthorized handling for revalidation failures. The
|
|
1402
|
+
`onConditionsFailed` Router event still fires for consistency with normal
|
|
1403
|
+
navigation. Pass `onRevalidationFailure: null` to clear and fall back to
|
|
1404
|
+
standard handling.
|
|
1405
|
+
|
|
1406
|
+
Calls to `revalidateCurrentRoute()` within a ~50ms window are coalesced into
|
|
1407
|
+
a single re-validation pass — safe to call on every websocket message.
|
|
1408
|
+
|
|
1276
1409
|
**2. Protect routes with permissions:**
|
|
1277
1410
|
|
|
1278
1411
|
#### Using createProtectedRoute() (Recommended)
|
|
@@ -1408,6 +1541,21 @@ Perfect for:
|
|
|
1408
1541
|
|
|
1409
1542
|
See `example-permissions/` for a complete working example with mock authentication.
|
|
1410
1543
|
|
|
1544
|
+
### Conditions vs Permissions
|
|
1545
|
+
|
|
1546
|
+
Both gate access to a route, but they have **different defaults** and **different failure paths**. Reach for the one that matches your situation:
|
|
1547
|
+
|
|
1548
|
+
| | `wrap({ conditions: [...] })` | `createProtectedRoute({ permissions: ... })` |
|
|
1549
|
+
|---|---|---|
|
|
1550
|
+
| **What it is** | Low-level primitive: any sync/async predicate(s) you want | Opinionated wrapper around conditions, built on the configured permission system |
|
|
1551
|
+
| **Setup needed** | None — just write the function | Call `configurePermissions({ checkPermissions, getCurrentUser, onUnauthorized })` once at app start |
|
|
1552
|
+
| **Where the logic lives** | Inline in the condition function | Inside `checkPermissions` (your function), which is reused across every protected route |
|
|
1553
|
+
| **On failure: UI** | **Empty slot.** The matched component does not mount; nothing renders in its place unless the consumer redirects | The configured `Unauthorized` component mounts (or `onUnauthorized` callback runs, if set), with `unauthorizedBehavior: 'component' \| 'navigate'` controlling which |
|
|
1554
|
+
| **On failure: event** | `onConditionsFailed` fires with `{ route, location, querystring, params }` | Same event fires (permissions are conditions under the hood); the unauthorized handling runs in addition |
|
|
1555
|
+
| **When to choose it** | Ad-hoc check that doesn't fit a generic permission model — feature flags, subscription state, ownership of a single resource, custom redirects | Role/permission-based access control where the same `checkPermissions` logic governs many routes and you want a consistent unauthorized UX |
|
|
1556
|
+
|
|
1557
|
+
**Common combo:** use `createProtectedRoute` for the role check (gets you the Unauthorized UI) *and* pass extra `conditions` for one-off checks specific to that route. The router runs them in order — permissions first (fast), then your custom conditions.
|
|
1558
|
+
|
|
1411
1559
|
### Active link highlighting
|
|
1412
1560
|
|
|
1413
1561
|
```svelte
|
|
@@ -1636,6 +1784,18 @@ await updateQuerystring({ search: null }, { dropNull: false }) // Keeps as ?sear
|
|
|
1636
1784
|
|
|
1637
1785
|
Prevent navigation when there's unsaved work or other conditions that need user confirmation.
|
|
1638
1786
|
|
|
1787
|
+
### Which mode should I use?
|
|
1788
|
+
|
|
1789
|
+
Three patterns, all calling the same underlying `registerBeforeLeave` primitive — the wrapper and helper are conveniences on top. Pick by ergonomics, not capability.
|
|
1790
|
+
|
|
1791
|
+
| Mode | Reach for it when… | Trade-off |
|
|
1792
|
+
|---|---|---|
|
|
1793
|
+
| **PageWrapper** *(declarative)* | You want a page-level guard tied to the component's lifecycle. Drop the wrapper around your page, write the `beforeLeave` function, done. | Adds one extra component in the markup. Less control over *when* the guard is active during the page's lifetime. |
|
|
1794
|
+
| **Direct Registration** *(imperative)* | You need fine control — swap the guard mid-session, toggle it based on a condition, share one guard across multiple components. Call `registerBeforeLeave` / `unregisterBeforeLeave` inside `onMount`/`onDestroy` (or a `$effect`). | You own the lifecycle — easy to forget the cleanup and leak guards across navigations. |
|
|
1795
|
+
| **`createDirtyCheckGuard`** *(shortcut)* | Your guard is the classic "form has unsaved changes — confirm before leaving" pattern. The helper bakes in the dirty-check + `confirm()` dialog; just plug in the dirty predicate. | Only fits the dirty-check shape. You still register it the same way as Direct mode — the helper just saves the `confirm` boilerplate. |
|
|
1796
|
+
|
|
1797
|
+
> **Tip:** the live demo at `/navigation-guard-demo` (in the example app) lets you switch between all three modes with the same form, so you can compare them side-by-side.
|
|
1798
|
+
|
|
1639
1799
|
### Basic Usage with PageWrapper
|
|
1640
1800
|
|
|
1641
1801
|
Create a reusable wrapper component:
|
|
@@ -1803,9 +1963,24 @@ const routes = {
|
|
|
1803
1963
|
onRouteLoading={(e) => console.log('Loading:', e.detail)}
|
|
1804
1964
|
onRouteLoaded={(e) => console.log('Loaded:', e.detail)}
|
|
1805
1965
|
onConditionsFailed={(e) => console.log('Failed:', e.detail)}
|
|
1966
|
+
onNotFound={(e) => console.log('Not found:', e.detail)}
|
|
1806
1967
|
/>
|
|
1807
1968
|
```
|
|
1808
1969
|
|
|
1970
|
+
**Event payloads (`e.detail`):**
|
|
1971
|
+
|
|
1972
|
+
| Event | Payload shape | Fires when |
|
|
1973
|
+
|---|---|---|
|
|
1974
|
+
| `onRouteLoading` | `{ route, location, relativeLocation, querystring, params }` | Before guards/conditions run for a matched route |
|
|
1975
|
+
| `onRouteLoaded` | `{ route, location, relativeLocation, querystring, params, component?, name?, routeContext?, zones? }` | After the matched component (and any async children) successfully mounts. `zones` is set for multi-zone routes; `component`/`name`/`routeContext` for single-component routes |
|
|
1976
|
+
| `onConditionsFailed` | `{ route, location, relativeLocation, querystring, params }` | A condition in `wrap({ conditions })` returned `false`. The slot is now empty — handle the redirect here or inside the condition itself |
|
|
1977
|
+
| `onNotFound` | `{ location, relativeLocation, querystring }` | No route matched, *or* the `'*'` catch-all matched (it fires for both, so consumers can always log 404s) |
|
|
1978
|
+
|
|
1979
|
+
**`location` vs `relativeLocation`:**
|
|
1980
|
+
- `location` is the **full app URL** as the browser sees it (e.g. `/test/embed/missing`). Use this for logging, analytics, or re-navigating with `push()` (which always takes app-wide paths).
|
|
1981
|
+
- `relativeLocation` is the URL **after the Router's `prefix` has been stripped** (e.g. `/missing` for a `<Router prefix="/test/embed" />`). Use this when you're reasoning about what *this* Router instance saw — it matches how routes inside the Router were defined.
|
|
1982
|
+
- For a root Router with no `prefix`, the two fields are identical.
|
|
1983
|
+
|
|
1809
1984
|
### Tree/Nested Route Structure
|
|
1810
1985
|
|
|
1811
1986
|
Define routes in a hierarchical tree structure as an alternative to flat definitions. Child paths are automatically concatenated to parent paths, and routes inherit metadata from parents.
|
|
@@ -2033,6 +2208,9 @@ configureGlobalErrorHandler({
|
|
|
2033
2208
|
onError: (error, errorInfo, context) => {
|
|
2034
2209
|
// Log to Sentry, LogRocket, etc.
|
|
2035
2210
|
Sentry.captureException(error, { extra: errorInfo })
|
|
2211
|
+
|
|
2212
|
+
// Show a toast/snackbar via your own UI library
|
|
2213
|
+
toast.error(`Something went wrong: ${error.message}`)
|
|
2036
2214
|
},
|
|
2037
2215
|
|
|
2038
2216
|
strategy: 'navigateSafe', // Navigate to home on error
|
|
@@ -2041,7 +2219,6 @@ configureGlobalErrorHandler({
|
|
|
2041
2219
|
maxRestarts: 3,
|
|
2042
2220
|
restartWindow: 60000, // 1 minute
|
|
2043
2221
|
|
|
2044
|
-
showToast: true,
|
|
2045
2222
|
isDevelopment: import.meta.env.DEV
|
|
2046
2223
|
})
|
|
2047
2224
|
```
|
|
@@ -2087,8 +2264,8 @@ configureGlobalErrorHandler({
|
|
|
2087
2264
|
|
|
2088
2265
|
- ✅ Catches ALL errors (render, effect, event handlers, async, promises)
|
|
2089
2266
|
- ✅ Loop prevention (tracks restarts in sessionStorage)
|
|
2090
|
-
- ✅
|
|
2091
|
-
- ✅
|
|
2267
|
+
- ✅ Full-page error UI (default `ErrorDisplay` or your own component)
|
|
2268
|
+
- ✅ `onError` callback for wiring up your toast/snackbar library, Sentry, analytics, etc.
|
|
2092
2269
|
- ✅ Error filtering (ignore known non-critical errors)
|
|
2093
2270
|
- ✅ TypeScript support
|
|
2094
2271
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keenmate/svelte-spa-router",
|
|
3
|
-
"version": "5.2.0-
|
|
3
|
+
"version": "5.2.0-rc02",
|
|
4
4
|
"description": "Router for SPAs using Svelte 5 with runes, dual-mode routing, permissions, and error handling",
|
|
5
5
|
"main": "./src/lib/index.js",
|
|
6
6
|
"svelte": "./src/lib/Router.svelte",
|
|
@@ -97,7 +97,11 @@
|
|
|
97
97
|
"test": "vitest run",
|
|
98
98
|
"test:watch": "vitest",
|
|
99
99
|
"test:ui": "vitest --ui",
|
|
100
|
-
"test:coverage": "vitest run --coverage"
|
|
100
|
+
"test:coverage": "vitest run --coverage",
|
|
101
|
+
"test:e2e": "playwright test",
|
|
102
|
+
"test:e2e:install": "playwright install chromium",
|
|
103
|
+
"test:e2e:ui": "playwright test --ui",
|
|
104
|
+
"test:e2e:headed": "playwright test --headed"
|
|
101
105
|
},
|
|
102
106
|
"repository": {
|
|
103
107
|
"type": "git",
|
|
@@ -124,6 +128,7 @@
|
|
|
124
128
|
"regexparam": "2.0.2"
|
|
125
129
|
},
|
|
126
130
|
"devDependencies": {
|
|
131
|
+
"@playwright/test": "^1.48.0",
|
|
127
132
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
128
133
|
"@testing-library/jest-dom": "^6.9.1",
|
|
129
134
|
"@testing-library/svelte": "^5.2.8",
|