@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 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 { configurePermissions } from '@keenmate/svelte-spa-router/helpers/permissions'
1245
- import { get } from 'svelte/store'
1246
- import { currentUser } from './stores/auth'
1295
+ import {
1296
+ configurePermissions,
1297
+ setCurrentUser
1298
+ } from '@keenmate/svelte-spa-router/helpers/permissions'
1247
1299
 
1248
1300
  configurePermissions({
1249
- checkPermissions: (user, requirements) => {
1250
- if (!user) return false
1251
- if (!requirements) return true
1252
-
1253
- // Check if user has any of the required permissions
1254
- if (requirements.any) {
1255
- return requirements.any.some(perm =>
1256
- user.permissions.includes(perm)
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
- // Check if user has all required permissions
1261
- if (requirements.all) {
1262
- return requirements.all.every(perm =>
1263
- user.permissions.includes(perm)
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
- return true
1268
- },
1269
- getCurrentUser: () => get(currentUser),
1270
- onUnauthorized: (detail) => {
1271
- push('/unauthorized')
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
- - ✅ Toast notifications or full-page error UI
2091
- - ✅ Custom error components
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-rc01",
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",