diff --git a/docs/packages/router.md b/docs/packages/router.md
index a3fa8ce..b1bbe02 100644
--- a/docs/packages/router.md
+++ b/docs/packages/router.md
@@ -155,17 +155,38 @@ Register navigation guards that run before or after route changes:
### Before Navigation
+A before-route middleware returns either a **boolean** or a **typed redirect object**. The first middleware that returns something truthy (a `true` or an object) short-circuits the chain — later middleware do not run.
+
+**Boolean form** — `true` cancels the pending navigation, a falsy value lets it continue:
+
```typescript
-const unregister = router.registerBeforeRouteMiddleware(async (to, from) => {
- // Check authentication
+const unregister = router.registerBeforeRouteMiddleware((to, from) => {
+ // Cancel navigation into a locked section
+ if (to.meta.locked) return true;
+ return false; // allow navigation
+});
+```
+
+**Redirect form** — return `{name, id?, query?, parentId?, replace?}` to **cancel the pending hop and navigate to the target in one step**. This is the type-safe replacement for the old cancel-then-`goToRoute` two-step (return a redirect object instead of calling `goToRoute` yourself and returning `true`):
+
+```typescript
+const unregister = router.registerBeforeRouteMiddleware((to) => {
+ // Bounce unauthenticated visitors to login
if (to.meta.requiresAuth && !isAuthenticated()) {
- router.goToRoute('login');
- return false; // prevent navigation
+ return {name: 'login'};
}
- return true; // allow navigation
+ return false;
});
```
+The redirect **pushes** by default (a new history entry). Set `replace: true` to replace the current entry instead — use it for bounces that should not be reachable via Back (a consumed OAuth callback, a 404-home redirect):
+
+```typescript
+router.registerBeforeRouteMiddleware((to) => (to.meta.deadLink ? {name: 'dashboard', replace: true} : false));
+```
+
+`id`, `query`, and `parentId` on the redirect object thread through to the target exactly as they do for `goToRoute`. The redirect object short-circuits the middleware chain identically to a truthy boolean.
+
### After Navigation
```typescript
@@ -194,6 +215,14 @@ import {router} from '@/services';
```
+`RouterLink` forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered ``, so a styled link keeps its styling:
+
+```vue
+Users
+```
+
+The computed `href` and the navigation `onClick` stay authoritative — a fallthrough `href` attribute cannot override them.
+
## URL Generation
Generate URLs without navigating:
@@ -227,29 +256,34 @@ const router = createRouterService(routes, {
/* ... */
},
],
+ notFoundComponent: NotFoundPage, // rendered by RouterView when no route matches
});
```
+Without `notFoundComponent`, `RouterView` renders a bare `404` string for an unmatched depth. Provide a component to render your own designed not-found page instead — pair it with a catch-all route (`{path: '/:pathMatch(.*)*', ...}`) to also own the URL.
+
## API Reference
### `createRouterService(routes, options?)`
-| Parameter | Type | Description |
-| ----------------------------- | ----------------------- | ----------------------------- |
-| `routes` | `RouteRecordRaw[]` | Route definitions |
-| `options.base` | `string` | Base path for routing |
-| `options.afterRouteCallbacks` | `NavigationHookAfter[]` | Global after-navigation hooks |
+| Parameter | Type | Description |
+| ----------------------------- | ----------------------- | -------------------------------------------------------------------- |
+| `routes` | `RouteRecordRaw[]` | Route definitions |
+| `options.base` | `string` | Base path for routing |
+| `options.afterRouteCallbacks` | `NavigationHookAfter[]` | Global after-navigation hooks |
+| `options.notFoundComponent` | `RouteComponent` | Rendered by `RouterView` on an unmatched route (default: bare `404`) |
### Navigation Methods
-| Method | Description |
-| ----------------------------------------- | ---------------------------------- |
-| `goToRoute(name, id?, query?, parentId?)` | Navigate to any named route |
-| `goToOverviewPage(name)` | Navigate to `name.overview` |
-| `goToCreatePage(name)` | Navigate to `name.create` |
-| `goToEditPage(name, id)` | Navigate to `name.edit` with `:id` |
-| `goToShowPage(name, id, query?)` | Navigate to `name.show` with `:id` |
-| `goBack()` | Navigate back in history |
+| Method | Description |
+| -------------------------------------------- | --------------------------------------------- |
+| `goToRoute(name, id?, query?, parentId?)` | Navigate to any named route (push) |
+| `replaceRoute(name, id?, query?, parentId?)` | Navigate, replacing the current history entry |
+| `goToOverviewPage(name)` | Navigate to `name.overview` |
+| `goToCreatePage(name)` | Navigate to `name.create` |
+| `goToEditPage(name, id)` | Navigate to `name.edit` with `:id` |
+| `goToShowPage(name, id, query?)` | Navigate to `name.show` with `:id` |
+| `goBack()` | Navigate back in history |
### Route State
diff --git a/package-lock.json b/package-lock.json
index 9c31272..805b58f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10505,7 +10505,7 @@
},
"packages/router": {
"name": "@script-development/fs-router",
- "version": "0.1.1",
+ "version": "0.2.0",
"license": "MIT",
"devDependencies": {
"@vue/test-utils": "^2.4.11",
diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md
index 54661c0..9836f49 100644
--- a/packages/router/CHANGELOG.md
+++ b/packages/router/CHANGELOG.md
@@ -1 +1,10 @@
# @script-development/fs-router
+
+## 0.2.0 — 2026-07-06
+
+### Minor Changes
+
+- **Middleware redirect-return.** A before-route middleware may now return a typed redirect object instead of just a boolean. Returning `{name, id?, query?, parentId?, replace?}` **cancels the pending navigation and navigates to the target in one step** — `goToRoute` (push) by default, or a replace when `replace: true`. A boolean return keeps its exact meaning (truthy cancels, falsy continues), and an object return short-circuits the middleware chain identically to a truthy boolean. This retires the hand-rolled cancel-then-`goToRoute` two-step consumers wrote against `registerBeforeRouteMiddleware`. Additive and backward-compatible: widening the middleware's return union is assignability-safe, so every existing `(to, from) => boolean` middleware still satisfies the type. New exports: `MiddlewareRedirect` and `BeforeRouteMiddlewareResult`.
+- **New export: `replaceRoute`.** A replace-semantics sibling of `goToRoute` with the identical signature (`name, id?, query?, parentId?`), delegating to `router.replace` with the same param resolution. Use it for landings that should not pollute the history stack (OAuth callbacks, 404-home bounces, dead-read redirects) — where a push would leave the consumed URL reachable via Back.
+- **New option: `notFoundComponent`.** `createRouterService(routes, { notFoundComponent })` lets `RouterView` render a declared component in place of the bare `404` fallback when no route matches at the requested depth. The default (a bare `404`) is unchanged when the option is omitted.
+- **`RouterLink` attribute fallthrough.** `RouterLink` now explicitly forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered `` via `inheritAttrs: false` plus an explicit attrs merge, keeping the owned `href` and `onClick` authoritative. `class` and `style` already fell through under Vue's automatic single-root attribute inheritance (Vue special-cases and concatenates them); the real defect this closes is that a **consumer-supplied `href` silently overrode the computed navigation `href`** — Vue's automatic merge lets an external non-class/style attribute win, so `` would navigate to the wrong target. Merging fallthrough attrs _before_ the owned props makes the computed `href`/`onClick` win, and non-navigational attrs (`class`, `data-*`, `aria-*`, …) still land on the anchor.
diff --git a/packages/router/package.json b/packages/router/package.json
index 0b64792..844c41d 100644
--- a/packages/router/package.json
+++ b/packages/router/package.json
@@ -1,6 +1,6 @@
{
"name": "@script-development/fs-router",
- "version": "0.1.1",
+ "version": "0.2.0",
"description": "Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router",
"homepage": "https://packages.script.nl/packages/router",
"license": "MIT",
diff --git a/packages/router/src/components.ts b/packages/router/src/components.ts
index 04b4316..526a7e1 100644
--- a/packages/router/src/components.ts
+++ b/packages/router/src/components.ts
@@ -1,5 +1,5 @@
import type {Ref} from 'vue';
-import type {LocationQueryRaw, RouteLocationNormalizedLoaded, RouteRecordRaw} from 'vue-router';
+import type {LocationQueryRaw, RouteComponent, RouteLocationNormalizedLoaded, RouteRecordRaw} from 'vue-router';
import {computed, defineComponent, h} from 'vue';
@@ -15,7 +15,10 @@ const buildRouteKey = (route: RouteLocationNormalizedLoaded, depth: number): str
return key;
};
-export const createRouterView = (currentRouteRef: Ref): RouterViewComponent =>
+export const createRouterView = (
+ currentRouteRef: Ref,
+ notFoundComponent?: RouteComponent,
+): RouterViewComponent =>
defineComponent<{depth?: number}>(
({depth = 0}) => {
const component = computed(() => {
@@ -24,7 +27,7 @@ export const createRouterView = (currentRouteRef: Ref {
- if (!component.value) return h('p', ['404']);
+ if (!component.value) return notFoundComponent ? h(notFoundComponent) : h('p', ['404']);
return h(component.value, {key: buildRouteKey(currentRouteRef.value, depth)});
};
@@ -39,11 +42,14 @@ export const createRouterLink = (
goToRoute: RouterService['goToRoute'],
): RouterLinkComponent =>
defineComponent<{to: {name: RouteName; query?: LocationQueryRaw; id?: number | string; parentId?: number}}>(
- (props, {slots}) =>
+ (props, {slots, attrs}) =>
() =>
h(
'a',
{
+ // Merge consumer-set fallthrough attrs (class/style/data-*/aria-*) onto the
+ // anchor; spread first so the owned href/onClick stay authoritative.
+ ...attrs,
href: getUrlForRouteName(props.to.name, props.to.id, props.to.query, props.to.parentId),
onClick: (event: MouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -56,5 +62,5 @@ export const createRouterLink = (
),
// https://vuejs.org/api/general.html#function-signature
// manual runtime props declaration is currently still needed
- {props: ['to']},
+ {props: ['to'], inheritAttrs: false},
);
diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts
index 292cadf..fa0f2a6 100644
--- a/packages/router/src/index.ts
+++ b/packages/router/src/index.ts
@@ -19,6 +19,8 @@ export type {
OverviewRouteName,
EditRouteName,
ShowRouteName,
+ MiddlewareRedirect,
+ BeforeRouteMiddlewareResult,
BeforeRouteMiddleware,
UnregisterMiddleware,
RouterViewComponent,
diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts
index c198ff8..2ea84ae 100644
--- a/packages/router/src/router.ts
+++ b/packages/router/src/router.ts
@@ -1,9 +1,9 @@
-import type {NavigationHookAfter, RouteLocationRaw, RouteRecordRaw} from 'vue-router';
+import type {LocationQueryRaw, NavigationHookAfter, RouteLocationRaw, RouteRecordRaw} from 'vue-router';
import {computed} from 'vue';
import {createRouter, createWebHistory} from 'vue-router';
-import type {BeforeRouteMiddleware, RouterService, RouterServiceOptions} from './types';
+import type {BeforeRouteMiddleware, RouteName, RouterService, RouterServiceOptions} from './types';
import {createRouterLink, createRouterView} from './components';
import {CREATE_PAGE_NAME, EDIT_PAGE_NAME, OVERVIEW_PAGE_NAME, SHOW_PAGE_NAME} from './routes';
@@ -45,14 +45,27 @@ export const createRouterService = (
return Object.fromEntries(Object.entries(params).filter(([key]) => targetPath.includes(`:${key}`)));
};
- const goToRoute: RouterService['goToRoute'] = async (name, id, query, parentId) => {
+ const buildRouteLocation = (
+ name: RouteName,
+ id?: number | string,
+ query?: LocationQueryRaw,
+ parentId?: number,
+ ): RouteLocationRaw => {
const route: RouteLocationRaw = {name};
const params = resolveRouteParams(name as string, id, parentId);
if (Object.keys(params).length > 0) route.params = params;
if (query) route.query = query;
- await router.push(route);
+ return route;
+ };
+
+ const goToRoute: RouterService['goToRoute'] = async (name, id, query, parentId) => {
+ await router.push(buildRouteLocation(name, id, query, parentId));
+ };
+
+ const replaceRoute: RouterService['replaceRoute'] = async (name, id, query, parentId) => {
+ await router.replace(buildRouteLocation(name, id, query, parentId));
};
const normalizedRouteToSpecificRoute: RouterService['normalizedRouteToSpecificRoute'] = (route) => {
@@ -66,13 +79,63 @@ export const createRouterService = (
const getUrlForRouteName: RouterService['getUrlForRouteName'] = (name, id, query, parentId) =>
router.resolve({name, params: resolveRouteParams(name as string, id, parentId), query}).fullPath;
+ // Bounds the middleware redirect-return chain. Each returned `MiddlewareRedirect` cancels the
+ // pending hop and dispatches a fresh navigation, which re-enters `beforeEach` and re-runs the
+ // whole chain — so two middleware that redirect into each other's guarded routes (a
+ // misconfigured login↔dashboard guard) would recurse without bound. `redirectDepth` counts
+ // consecutive redirects and is reset to 0 the moment a chain terminates — a navigation is
+ // allowed to proceed, or a middleware cancels without redirecting — so the cap only ever trips
+ // on a genuine loop, never on unrelated back-to-back navigations. Mirrors vue-router's own
+ // internal max-redirect ceiling.
+ const MAX_REDIRECT_DEPTH = 10;
+ let redirectDepth = 0;
+
const beforeRouteMiddleware: BeforeRouteMiddleware[] = [];
router.beforeEach(async (to, from) => {
const toNormalized = normalizedRouteToSpecificRoute(to);
const fromNormalized = from.name ? normalizedRouteToSpecificRoute(from) : toNormalized;
- for (const middleware of beforeRouteMiddleware)
- if (await middleware(toNormalized, fromNormalized)) return false;
+ let cancelled = false;
+ for (const middleware of beforeRouteMiddleware) {
+ const result = await middleware(toNormalized, fromNormalized);
+ if (result === false) continue;
+
+ // A truthy object return cancels the pending hop and navigates to the target in one
+ // step — replace when `replace: true`, push otherwise. A boolean `true` just cancels.
+ if (result !== true) {
+ if (redirectDepth >= MAX_REDIRECT_DEPTH) {
+ redirectDepth = 0;
+ console.error(
+ `fs-router: middleware redirect chain exceeded ${MAX_REDIRECT_DEPTH} hops — aborting to break a redirect loop`,
+ );
+
+ return false;
+ }
+
+ redirectDepth += 1;
+ // Fire-and-forget: the dispatch resolves once the redirect navigation settles.
+ // `router.push`/`replace` resolve (not reject) for NavigationDuplicated/Aborted, but
+ // they DO reject when a guard or lazy component further down the redirected chain
+ // throws — attach a reporter so that surfaces instead of an unhandled rejection.
+ const dispatch = result.replace ? replaceRoute : goToRoute;
+ void dispatch(result.name, result.id, result.query, result.parentId).catch((error: unknown) => {
+ console.error('fs-router: middleware redirect navigation failed', error);
+ });
+
+ // The chain continues in the dispatched navigation — do NOT reset the depth here.
+ return false;
+ }
+
+ // Plain `true` cancels the hop without redirecting; the chain terminates.
+ cancelled = true;
+ break;
+ }
+
+ // The chain terminated without dispatching a redirect (a `true` cancel or a clean proceed),
+ // so reset the depth for the next, unrelated navigation.
+ redirectDepth = 0;
+
+ return cancelled ? false : undefined;
});
const afterRouteMiddleware: NavigationHookAfter[] = [...(options?.afterRouteCallbacks ?? [])];
@@ -99,6 +162,7 @@ export const createRouterService = (
normalizedRouteToSpecificRoute,
goToRoute,
+ replaceRoute,
goToCreatePage: (name) => goToRoute(`${name}${CREATE_PAGE_NAME}`),
goToOverviewPage: (name) => goToRoute(`${name}${OVERVIEW_PAGE_NAME}`),
goToEditPage: (name, id) => goToRoute(`${name}${EDIT_PAGE_NAME}`, id),
@@ -160,7 +224,7 @@ export const createRouterService = (
}
},
- RouterView: createRouterView(currentRouteRef),
+ RouterView: createRouterView(currentRouteRef, options?.notFoundComponent),
RouterLink: createRouterLink(getUrlForRouteName, goToRoute),
};
};
diff --git a/packages/router/src/types.ts b/packages/router/src/types.ts
index 555794a..b28a5de 100644
--- a/packages/router/src/types.ts
+++ b/packages/router/src/types.ts
@@ -44,10 +44,47 @@ export type OverviewRouteName = ExtractNa
export type EditRouteName = ExtractNameFromRoutes;
export type ShowRouteName = ExtractNameFromRoutes;
+/**
+ * A typed redirect returned from a before-route middleware. Returning one from a
+ * middleware cancels the pending hop and navigates to the target in a single step —
+ * `goToRoute` (push) by default, `replaceRoute` when `replace: true`.
+ */
+export type MiddlewareRedirect = {
+ name: RouteName;
+ id?: number | string;
+ query?: LocationQueryRaw;
+ parentId?: number;
+ /** Replace the current history entry instead of pushing a new one. Defaults to `false`. */
+ replace?: boolean;
+};
+
+/**
+ * The return of a before-route middleware: a boolean (truthy = cancel the hop,
+ * falsy = continue) or a typed {@link MiddlewareRedirect} (cancel-and-navigate).
+ * The first middleware returning truthy — boolean or object — short-circuits the chain.
+ */
+export type BeforeRouteMiddlewareResult = boolean | MiddlewareRedirect;
+
export type BeforeRouteMiddleware = (
to: ActualRoute,
from: ActualRoute,
-) => boolean | Promise;
+) => BeforeRouteMiddlewareResult | Promise>;
+
+/**
+ * Compile-time compat proof for the 0.2.0 middleware redirect-return widening.
+ * `BeforeRouteMiddleware`'s return union grew from `boolean | Promise` to
+ * also admit a typed {@link MiddlewareRedirect}; widening a function's RETURN is
+ * assignability-safe, so every existing `(to, from) => boolean` middleware still
+ * satisfies the type. `tsc` fails here if that guarantee ever regresses.
+ */
+type AssertTrue = T;
+export type LegacyBooleanMiddlewareStillSatisfies = AssertTrue<
+ ((to: ActualRoute, from: ActualRoute) => boolean) extends BeforeRouteMiddleware<
+ RouteRecordRaw[]
+ >
+ ? true
+ : false
+>;
export type UnregisterMiddleware = () => void;
@@ -59,6 +96,8 @@ export type RouterLinkComponent = DefineSetupFn
export interface RouterServiceOptions {
base?: string;
afterRouteCallbacks?: NavigationHookAfter[];
+ /** Component rendered by `RouterView` in place of the bare `404` fallback when no route matches. */
+ notFoundComponent?: RouteComponent;
}
export interface RouterService {
@@ -70,6 +109,12 @@ export interface RouterService {
query?: LocationQueryRaw,
parentId?: number,
) => Promise;
+ replaceRoute: (
+ name: RouteName,
+ id?: number | string,
+ query?: LocationQueryRaw,
+ parentId?: number,
+ ) => Promise;
goToCreatePage: (name: CreateRouteName>) => Promise;
goToOverviewPage: (name: OverviewRouteName>) => Promise;
goToEditPage: (name: EditRouteName>, id: number | string) => Promise;
diff --git a/packages/router/tests/components.spec.ts b/packages/router/tests/components.spec.ts
index c0f8778..0afb715 100644
--- a/packages/router/tests/components.spec.ts
+++ b/packages/router/tests/components.spec.ts
@@ -137,6 +137,44 @@ describe('createRouterView', () => {
// Assert
expect(wrapper.text()).toBe('404');
});
+
+ it('should render a custom notFoundComponent in place of the bare 404 when unmatched', () => {
+ // Arrange
+ const NotFound = defineComponent({name: 'NotFound', render: () => h('div', {class: 'custom-404'}, 'Not here')});
+ const routeRef = ref({matched: [], path: '/nope', params: {}} as unknown as RouteLocationNormalizedLoaded);
+ const RouterView = createRouterView(routeRef, NotFound);
+
+ // Act
+ const wrapper = mount(RouterView);
+
+ // Assert — the custom component renders, not the bare '404'
+ expect(wrapper.text()).toBe('Not here');
+ expect(wrapper.find('.custom-404').exists()).toBe(true);
+ });
+
+ it('should render the bare 404 fallback when no notFoundComponent is provided', () => {
+ // Arrange
+ const routeRef = ref({matched: [], path: '/nope', params: {}} as unknown as RouteLocationNormalizedLoaded);
+ const RouterView = createRouterView(routeRef);
+
+ // Act
+ const wrapper = mount(RouterView);
+
+ // Assert
+ expect(wrapper.text()).toBe('404');
+ });
+
+ it('should thread notFoundComponent from createRouterService options into RouterView', () => {
+ // Arrange — an unmatched depth renders the option-provided fallback
+ const NotFound = defineComponent({name: 'NotFound', render: () => h('div', 'service-404')});
+ const service = createRouterService(createTestRoutes(), {notFoundComponent: NotFound});
+
+ // Act — depth 5 never matches, forcing the fallback path
+ const wrapper = mount(service.RouterView, {props: {depth: 5}});
+
+ // Assert
+ expect(wrapper.text()).toBe('service-404');
+ });
});
describe('createRouterLink', () => {
@@ -247,6 +285,42 @@ describe('createRouterLink', () => {
expect(goTo).not.toHaveBeenCalled();
});
+ it('should forward class, style, data-*, and aria-* attributes to the anchor', () => {
+ // Arrange
+ const getUrl = vi.fn().mockReturnValue('/about');
+ const goTo = vi.fn();
+ const RouterLink = createRouterLink(getUrl, goTo);
+
+ // Act
+ const wrapper = mount(RouterLink, {
+ props: {to: {name: 'about'}},
+ attrs: {class: 'nav-link active', style: 'color: red;', 'data-testid': 'home-link', 'aria-current': 'page'},
+ });
+
+ // Assert — consumer attributes land on the anchor
+ const anchor = wrapper.find('a');
+ expect(anchor.classes()).toContain('nav-link');
+ expect(anchor.classes()).toContain('active');
+ expect(anchor.attributes('style')).toContain('color: red');
+ expect(anchor.attributes('data-testid')).toBe('home-link');
+ expect(anchor.attributes('aria-current')).toBe('page');
+ // ...and the owned href stays present
+ expect(anchor.attributes('href')).toBe('/about');
+ });
+
+ it('should keep the owned href authoritative over a fallthrough href attribute', () => {
+ // Arrange
+ const getUrl = vi.fn().mockReturnValue('/canonical');
+ const goTo = vi.fn();
+ const RouterLink = createRouterLink(getUrl, goTo);
+
+ // Act — a consumer-supplied href must not override the computed one
+ const wrapper = mount(RouterLink, {props: {to: {name: 'about'}}, attrs: {href: '/consumer-supplied'}});
+
+ // Assert — attrs spread first, so the owned href wins
+ expect(wrapper.find('a').attributes('href')).toBe('/canonical');
+ });
+
it('should pass name, id, query, and parentId to getUrlForRouteName', () => {
// Arrange
const getUrl = vi.fn().mockReturnValue('/parent/5/child/10');
diff --git a/packages/router/tests/router.spec.ts b/packages/router/tests/router.spec.ts
index f7237fe..9feac0b 100644
--- a/packages/router/tests/router.spec.ts
+++ b/packages/router/tests/router.spec.ts
@@ -256,6 +256,80 @@ describe('router service', () => {
});
});
+ describe('replaceRoute', () => {
+ it('should navigate to named route', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+
+ // Act
+ await service.replaceRoute('about');
+ await flushPromises();
+
+ // Assert
+ expect(service.currentRouteRef.value.name).toBe('about');
+ });
+
+ it('should navigate with id param', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+
+ // Act
+ await service.replaceRoute('items.show', 42);
+ await flushPromises();
+
+ // Assert
+ expect(service.currentRouteRef.value.params.id).toBe('42');
+ });
+
+ it('should navigate with query params', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+
+ // Act
+ await service.replaceRoute('about', undefined, {page: '3'});
+ await flushPromises();
+
+ // Assert
+ expect(service.currentRouteRef.value.query.page).toBe('3');
+ });
+
+ it('should navigate with parentId override', async () => {
+ // Arrange
+ const routes: RouteRecordRaw[] = [
+ {path: '/', name: 'home', component: TestPage},
+ {path: '/parent/:parentId/child/:id', name: 'nested', component: TestPage},
+ ];
+ const service = createRouterService(routes);
+
+ // Act
+ await service.replaceRoute('nested', 10, undefined, 5);
+ await flushPromises();
+
+ // Assert
+ expect(service.currentRouteRef.value.params.id).toBe('10');
+ expect(service.currentRouteRef.value.params.parentId).toBe('5');
+ });
+
+ it('should replace the current history entry instead of pushing', async () => {
+ // Arrange — build a two-entry history, then replace the top
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Act
+ await service.replaceRoute('items.overview');
+ await flushPromises();
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+
+ // Assert — 'about' was replaced (not pushed over), so Back lands on 'home'
+ service.goBack();
+ await flushPromises();
+ expect(service.currentRouteRef.value.name).toBe('home');
+ });
+ });
+
describe('CRUD navigation shortcuts', () => {
it('goToCreatePage should navigate to .create route', async () => {
// Arrange
@@ -481,13 +555,19 @@ describe('router service', () => {
const middleware = vi.fn<() => boolean>().mockReturnValue(true);
service.registerBeforeRouteMiddleware(middleware);
+ const pushSpy = vi.spyOn(window.history, 'pushState');
+ const replaceSpy = vi.spyOn(window.history, 'replaceState');
// Act
await service.goToRoute('about');
await flushPromises();
- // Assert
+ // Assert — a pure boolean cancel neither commits the hop nor kicks off any
+ // redirect navigation, so no history mutation happens at all
expect(service.currentRouteRef.value.name).not.toBe('about');
+ expect(service.currentRouteRef.value.name).toBe('home');
+ expect(pushSpy).not.toHaveBeenCalled();
+ expect(replaceSpy).not.toHaveBeenCalled();
});
it('should not execute middleware after unregistering', async () => {
@@ -552,6 +632,258 @@ describe('router service', () => {
// Assert — from should equal to since initial route has no name
expect(fromRoute).toBe(toRoute);
});
+
+ it('should let navigation complete when middleware returns false', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+ service.registerBeforeRouteMiddleware(() => false);
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — a falsy return does not cancel or redirect
+ expect(service.currentRouteRef.value.name).toBe('about');
+ });
+
+ it('should cancel the hop and navigate to the target when middleware returns a redirect object', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — the redirect target committed, not the blocked 'about' hop
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ });
+
+ it('should short-circuit later middleware when a redirect object is returned', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
+ const observed: string[] = [];
+ service.registerBeforeRouteMiddleware((to) => {
+ observed.push(String(to.name));
+ return false;
+ });
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — the redirecting middleware returns object → chain stops, blocked hop never
+ // reaches the follow-on middleware (a dropped `return false` would let it through)
+ expect(observed).not.toContain('about');
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ });
+
+ it('should carry id, query, and parentId from the redirect object to the target', async () => {
+ // Arrange
+ const routes: RouteRecordRaw[] = [
+ {path: '/', name: 'home', component: TestPage},
+ {path: '/parent/:parentId/child/:id', name: 'nested', component: TestPage},
+ {path: '/start', name: 'start', component: TestPage},
+ ];
+ const service = createRouterService(routes);
+ await service.goToRoute('home');
+ await flushPromises();
+ service.registerBeforeRouteMiddleware((to) =>
+ to.name === 'start' ? {name: 'nested', id: 10, query: {tab: 'x'}, parentId: 5} : false,
+ );
+
+ // Act
+ await service.goToRoute('start');
+ await flushPromises();
+
+ // Assert
+ expect(service.currentRouteRef.value.name).toBe('nested');
+ expect(service.currentRouteRef.value.params.id).toBe('10');
+ expect(service.currentRouteRef.value.params.parentId).toBe('5');
+ expect(service.currentRouteRef.value.query.tab).toBe('x');
+ });
+
+ it('should use push semantics when the redirect omits replace', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
+ const pushSpy = vi.spyOn(window.history, 'pushState');
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — a push navigation calls history.pushState (vue-router also replaceStates to
+ // save scroll on every hop, so pushState is the discriminator between push and replace)
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ expect(pushSpy).toHaveBeenCalled();
+ });
+
+ it('should use replace semantics when the redirect sets replace:true', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ service.registerBeforeRouteMiddleware((to) =>
+ to.name === 'about' ? {name: 'items.overview', replace: true} : false,
+ );
+ const pushSpy = vi.spyOn(window.history, 'pushState');
+ const replaceSpy = vi.spyOn(window.history, 'replaceState');
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — a replace navigation replaceStates but never pushStates (the discriminator)
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ expect(replaceSpy).toHaveBeenCalled();
+ expect(pushSpy).not.toHaveBeenCalled();
+ });
+
+ it('should redirect when an async middleware resolves a redirect object', async () => {
+ // Arrange
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ service.registerBeforeRouteMiddleware(async (to) =>
+ to.name === 'about' ? {name: 'items.overview'} : false,
+ );
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — the awaited object return redirects just like a sync one
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ });
+
+ it('should not commit the blocked hop when a middleware returns a redirect', async () => {
+ // Arrange — a single redirect (about → items.overview) with an afterEach recorder that
+ // logs only *successful* commits (failure === undefined).
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ const committed: (string | symbol | undefined)[] = [];
+ service.registerAfterRouteMiddleware((to, _from, failure) => {
+ if (!failure) committed.push(to.name);
+ });
+ service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
+
+ // Act
+ await service.goToRoute('about');
+ await flushPromises();
+
+ // Assert — the blocked 'about' hop is aborted, never committed; only the redirect target
+ // lands (a redirect that returned `true` instead of `false` would commit 'about' first)
+ expect(committed).not.toContain('about');
+ expect(committed).toContain('items.overview');
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ });
+
+ it('should abort a self-redirecting middleware loop instead of recursing without bound', async () => {
+ // Arrange — a middleware that redirects 'about' back to 'about'. The blocked hop never
+ // commits, so 'from' stays 'home' and every re-dispatch redirects again — an unbounded
+ // loop without the depth cap.
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'about'} : false));
+
+ // Act
+ await service.goToRoute('about');
+
+ // Assert — the chain hits the depth cap, logs, and stops (a broken guard would hang here)
+ await vi.waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('redirect chain exceeded'));
+ });
+ await flushPromises();
+
+ // ...and the aborted hop is cancelled, not committed — the router stays on 'home'
+ // (a cap branch that returned `true` would let the final blocked 'about' hop through)
+ expect(service.currentRouteRef.value.name).toBe('home');
+ });
+
+ it('should resume normal redirects after a loop has been aborted', async () => {
+ // Arrange — trip the loop guard once...
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const unregisterLoop = service.registerBeforeRouteMiddleware((to) =>
+ to.name === 'about' ? {name: 'about'} : false,
+ );
+ await service.goToRoute('about');
+ await vi.waitFor(() => expect(consoleErrorSpy).toHaveBeenCalled());
+ await flushPromises();
+
+ // ...then tear the loop down and install a single clean redirect.
+ unregisterLoop();
+ consoleErrorSpy.mockClear();
+ service.registerBeforeRouteMiddleware((to) =>
+ to.name === 'items.create' ? {name: 'items.edit', id: 7} : false,
+ );
+
+ // Act
+ await service.goToRoute('items.create');
+ await flushPromises();
+
+ // Assert — the depth was reset when the loop aborted, so a fresh redirect still works
+ // (a dropped reset-on-cap would leave the counter pinned at the cap and abort this too)
+ expect(service.currentRouteRef.value.name).toBe('items.edit');
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not accumulate redirect depth across independent navigations', async () => {
+ // Arrange — a single-hop redirect, exercised far more times than the cap allows.
+ const service = createRouterService(createTestRoutes());
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ service.registerBeforeRouteMiddleware((to) => (to.name === 'about' ? {name: 'items.overview'} : false));
+
+ // Act — 15 unrelated redirects; each chain terminates cleanly and must reset the depth
+ for (let index = 0; index < 15; index += 1) {
+ await service.goToRoute('home');
+ await flushPromises();
+ await service.goToRoute('about');
+ await flushPromises();
+ }
+
+ // Assert — the last redirect still fires and the cap never trips (a dropped reset would
+ // let the depth climb across the 15 hops and falsely abort after ten)
+ expect(service.currentRouteRef.value.name).toBe('items.overview');
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+ });
+
+ it('should report a rejected redirect dispatch instead of leaving an unhandled rejection', async () => {
+ // Arrange — the redirect target's own guard throws, so the dispatched navigation rejects.
+ const service = createRouterService(createTestRoutes());
+ await service.goToRoute('home');
+ await flushPromises();
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ service.registerBeforeRouteMiddleware((to) => {
+ if (to.name === 'items.overview') throw new Error('downstream guard boom');
+
+ return to.name === 'about' ? {name: 'items.overview'} : false;
+ });
+
+ // Act
+ await service.goToRoute('about');
+
+ // Assert — the fire-and-forget dispatch's rejection is caught and reported
+ await vi.waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'fs-router: middleware redirect navigation failed',
+ expect.any(Error),
+ );
+ });
+ });
});
describe('registerAfterRouteMiddleware', () => {