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', () => {