Skip to content

Commit 6147d5e

Browse files
authored
Merge pull request #147 from script-development/engineer/fs-router-0.2.0
feat(router): middleware redirect-return, replaceRoute, notFoundComponent, RouterLink fallthrough (fs-router 0.2.0)
2 parents bf80d2c + 1e6d745 commit 6147d5e

10 files changed

Lines changed: 600 additions & 34 deletions

File tree

docs/packages/router.md

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -155,17 +155,38 @@ Register navigation guards that run before or after route changes:
155155

156156
### Before Navigation
157157

158+
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.
159+
160+
**Boolean form**`true` cancels the pending navigation, a falsy value lets it continue:
161+
158162
```typescript
159-
const unregister = router.registerBeforeRouteMiddleware(async (to, from) => {
160-
// Check authentication
163+
const unregister = router.registerBeforeRouteMiddleware((to, from) => {
164+
// Cancel navigation into a locked section
165+
if (to.meta.locked) return true;
166+
return false; // allow navigation
167+
});
168+
```
169+
170+
**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`):
171+
172+
```typescript
173+
const unregister = router.registerBeforeRouteMiddleware((to) => {
174+
// Bounce unauthenticated visitors to login
161175
if (to.meta.requiresAuth && !isAuthenticated()) {
162-
router.goToRoute('login');
163-
return false; // prevent navigation
176+
return {name: 'login'};
164177
}
165-
return true; // allow navigation
178+
return false;
166179
});
167180
```
168181

182+
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):
183+
184+
```typescript
185+
router.registerBeforeRouteMiddleware((to) => (to.meta.deadLink ? {name: 'dashboard', replace: true} : false));
186+
```
187+
188+
`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.
189+
169190
### After Navigation
170191

171192
```typescript
@@ -194,6 +215,14 @@ import {router} from '@/services';
194215
</template>
195216
```
196217

218+
`RouterLink` forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered `<a>`, so a styled link keeps its styling:
219+
220+
```vue
221+
<router.RouterLink :to="{name: 'users.overview'}" class="nav-link" aria-current="page">Users</router.RouterLink>
222+
```
223+
224+
The computed `href` and the navigation `onClick` stay authoritative — a fallthrough `href` attribute cannot override them.
225+
197226
## URL Generation
198227

199228
Generate URLs without navigating:
@@ -227,29 +256,34 @@ const router = createRouterService(routes, {
227256
/* ... */
228257
},
229258
],
259+
notFoundComponent: NotFoundPage, // rendered by RouterView when no route matches
230260
});
231261
```
232262

263+
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.
264+
233265
## API Reference
234266

235267
### `createRouterService(routes, options?)`
236268

237-
| Parameter | Type | Description |
238-
| ----------------------------- | ----------------------- | ----------------------------- |
239-
| `routes` | `RouteRecordRaw[]` | Route definitions |
240-
| `options.base` | `string` | Base path for routing |
241-
| `options.afterRouteCallbacks` | `NavigationHookAfter[]` | Global after-navigation hooks |
269+
| Parameter | Type | Description |
270+
| ----------------------------- | ----------------------- | -------------------------------------------------------------------- |
271+
| `routes` | `RouteRecordRaw[]` | Route definitions |
272+
| `options.base` | `string` | Base path for routing |
273+
| `options.afterRouteCallbacks` | `NavigationHookAfter[]` | Global after-navigation hooks |
274+
| `options.notFoundComponent` | `RouteComponent` | Rendered by `RouterView` on an unmatched route (default: bare `404`) |
242275

243276
### Navigation Methods
244277

245-
| Method | Description |
246-
| ----------------------------------------- | ---------------------------------- |
247-
| `goToRoute(name, id?, query?, parentId?)` | Navigate to any named route |
248-
| `goToOverviewPage(name)` | Navigate to `name.overview` |
249-
| `goToCreatePage(name)` | Navigate to `name.create` |
250-
| `goToEditPage(name, id)` | Navigate to `name.edit` with `:id` |
251-
| `goToShowPage(name, id, query?)` | Navigate to `name.show` with `:id` |
252-
| `goBack()` | Navigate back in history |
278+
| Method | Description |
279+
| -------------------------------------------- | --------------------------------------------- |
280+
| `goToRoute(name, id?, query?, parentId?)` | Navigate to any named route (push) |
281+
| `replaceRoute(name, id?, query?, parentId?)` | Navigate, replacing the current history entry |
282+
| `goToOverviewPage(name)` | Navigate to `name.overview` |
283+
| `goToCreatePage(name)` | Navigate to `name.create` |
284+
| `goToEditPage(name, id)` | Navigate to `name.edit` with `:id` |
285+
| `goToShowPage(name, id, query?)` | Navigate to `name.show` with `:id` |
286+
| `goBack()` | Navigate back in history |
253287

254288
### Route State
255289

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/router/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
11
# @script-development/fs-router
2+
3+
## 0.2.0 — 2026-07-06
4+
5+
### Minor Changes
6+
7+
- **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`.
8+
- **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.
9+
- **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.
10+
- **`RouterLink` attribute fallthrough.** `RouterLink` now explicitly forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered `<a>` 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 `<RouterLink :to="…" href="…">` 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.

packages/router/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-router",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router",
55
"homepage": "https://packages.script.nl/packages/router",
66
"license": "MIT",

packages/router/src/components.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {Ref} from 'vue';
2-
import type {LocationQueryRaw, RouteLocationNormalizedLoaded, RouteRecordRaw} from 'vue-router';
2+
import type {LocationQueryRaw, RouteComponent, RouteLocationNormalizedLoaded, RouteRecordRaw} from 'vue-router';
33

44
import {computed, defineComponent, h} from 'vue';
55

@@ -15,7 +15,10 @@ const buildRouteKey = (route: RouteLocationNormalizedLoaded, depth: number): str
1515
return key;
1616
};
1717

18-
export const createRouterView = (currentRouteRef: Ref<RouteLocationNormalizedLoaded>): RouterViewComponent =>
18+
export const createRouterView = (
19+
currentRouteRef: Ref<RouteLocationNormalizedLoaded>,
20+
notFoundComponent?: RouteComponent,
21+
): RouterViewComponent =>
1922
defineComponent<{depth?: number}>(
2023
({depth = 0}) => {
2124
const component = computed(() => {
@@ -24,7 +27,7 @@ export const createRouterView = (currentRouteRef: Ref<RouteLocationNormalizedLoa
2427
});
2528

2629
return () => {
27-
if (!component.value) return h('p', ['404']);
30+
if (!component.value) return notFoundComponent ? h(notFoundComponent) : h('p', ['404']);
2831

2932
return h(component.value, {key: buildRouteKey(currentRouteRef.value, depth)});
3033
};
@@ -39,11 +42,14 @@ export const createRouterLink = <Routes extends RouteRecordRaw[]>(
3942
goToRoute: RouterService<Routes>['goToRoute'],
4043
): RouterLinkComponent<Routes> =>
4144
defineComponent<{to: {name: RouteName<Routes>; query?: LocationQueryRaw; id?: number | string; parentId?: number}}>(
42-
(props, {slots}) =>
45+
(props, {slots, attrs}) =>
4346
() =>
4447
h(
4548
'a',
4649
{
50+
// Merge consumer-set fallthrough attrs (class/style/data-*/aria-*) onto the
51+
// anchor; spread first so the owned href/onClick stay authoritative.
52+
...attrs,
4753
href: getUrlForRouteName(props.to.name, props.to.id, props.to.query, props.to.parentId),
4854
onClick: (event: MouseEvent) => {
4955
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -56,5 +62,5 @@ export const createRouterLink = <Routes extends RouteRecordRaw[]>(
5662
),
5763
// https://vuejs.org/api/general.html#function-signature
5864
// manual runtime props declaration is currently still needed
59-
{props: ['to']},
65+
{props: ['to'], inheritAttrs: false},
6066
);

packages/router/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type {
1919
OverviewRouteName,
2020
EditRouteName,
2121
ShowRouteName,
22+
MiddlewareRedirect,
23+
BeforeRouteMiddlewareResult,
2224
BeforeRouteMiddleware,
2325
UnregisterMiddleware,
2426
RouterViewComponent,

packages/router/src/router.ts

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import type {NavigationHookAfter, RouteLocationRaw, RouteRecordRaw} from 'vue-router';
1+
import type {LocationQueryRaw, NavigationHookAfter, RouteLocationRaw, RouteRecordRaw} from 'vue-router';
22

33
import {computed} from 'vue';
44
import {createRouter, createWebHistory} from 'vue-router';
55

6-
import type {BeforeRouteMiddleware, RouterService, RouterServiceOptions} from './types';
6+
import type {BeforeRouteMiddleware, RouteName, RouterService, RouterServiceOptions} from './types';
77

88
import {createRouterLink, createRouterView} from './components';
99
import {CREATE_PAGE_NAME, EDIT_PAGE_NAME, OVERVIEW_PAGE_NAME, SHOW_PAGE_NAME} from './routes';
@@ -45,14 +45,27 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
4545
return Object.fromEntries(Object.entries(params).filter(([key]) => targetPath.includes(`:${key}`)));
4646
};
4747

48-
const goToRoute: RouterService<Routes>['goToRoute'] = async (name, id, query, parentId) => {
48+
const buildRouteLocation = (
49+
name: RouteName<Routes>,
50+
id?: number | string,
51+
query?: LocationQueryRaw,
52+
parentId?: number,
53+
): RouteLocationRaw => {
4954
const route: RouteLocationRaw = {name};
5055
const params = resolveRouteParams(name as string, id, parentId);
5156

5257
if (Object.keys(params).length > 0) route.params = params;
5358
if (query) route.query = query;
5459

55-
await router.push(route);
60+
return route;
61+
};
62+
63+
const goToRoute: RouterService<Routes>['goToRoute'] = async (name, id, query, parentId) => {
64+
await router.push(buildRouteLocation(name, id, query, parentId));
65+
};
66+
67+
const replaceRoute: RouterService<Routes>['replaceRoute'] = async (name, id, query, parentId) => {
68+
await router.replace(buildRouteLocation(name, id, query, parentId));
5669
};
5770

5871
const normalizedRouteToSpecificRoute: RouterService<Routes>['normalizedRouteToSpecificRoute'] = (route) => {
@@ -66,13 +79,63 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
6679
const getUrlForRouteName: RouterService<Routes>['getUrlForRouteName'] = (name, id, query, parentId) =>
6780
router.resolve({name, params: resolveRouteParams(name as string, id, parentId), query}).fullPath;
6881

82+
// Bounds the middleware redirect-return chain. Each returned `MiddlewareRedirect` cancels the
83+
// pending hop and dispatches a fresh navigation, which re-enters `beforeEach` and re-runs the
84+
// whole chain — so two middleware that redirect into each other's guarded routes (a
85+
// misconfigured login↔dashboard guard) would recurse without bound. `redirectDepth` counts
86+
// consecutive redirects and is reset to 0 the moment a chain terminates — a navigation is
87+
// allowed to proceed, or a middleware cancels without redirecting — so the cap only ever trips
88+
// on a genuine loop, never on unrelated back-to-back navigations. Mirrors vue-router's own
89+
// internal max-redirect ceiling.
90+
const MAX_REDIRECT_DEPTH = 10;
91+
let redirectDepth = 0;
92+
6993
const beforeRouteMiddleware: BeforeRouteMiddleware<Routes>[] = [];
7094
router.beforeEach(async (to, from) => {
7195
const toNormalized = normalizedRouteToSpecificRoute(to);
7296
const fromNormalized = from.name ? normalizedRouteToSpecificRoute(from) : toNormalized;
7397

74-
for (const middleware of beforeRouteMiddleware)
75-
if (await middleware(toNormalized, fromNormalized)) return false;
98+
let cancelled = false;
99+
for (const middleware of beforeRouteMiddleware) {
100+
const result = await middleware(toNormalized, fromNormalized);
101+
if (result === false) continue;
102+
103+
// A truthy object return cancels the pending hop and navigates to the target in one
104+
// step — replace when `replace: true`, push otherwise. A boolean `true` just cancels.
105+
if (result !== true) {
106+
if (redirectDepth >= MAX_REDIRECT_DEPTH) {
107+
redirectDepth = 0;
108+
console.error(
109+
`fs-router: middleware redirect chain exceeded ${MAX_REDIRECT_DEPTH} hops — aborting to break a redirect loop`,
110+
);
111+
112+
return false;
113+
}
114+
115+
redirectDepth += 1;
116+
// Fire-and-forget: the dispatch resolves once the redirect navigation settles.
117+
// `router.push`/`replace` resolve (not reject) for NavigationDuplicated/Aborted, but
118+
// they DO reject when a guard or lazy component further down the redirected chain
119+
// throws — attach a reporter so that surfaces instead of an unhandled rejection.
120+
const dispatch = result.replace ? replaceRoute : goToRoute;
121+
void dispatch(result.name, result.id, result.query, result.parentId).catch((error: unknown) => {
122+
console.error('fs-router: middleware redirect navigation failed', error);
123+
});
124+
125+
// The chain continues in the dispatched navigation — do NOT reset the depth here.
126+
return false;
127+
}
128+
129+
// Plain `true` cancels the hop without redirecting; the chain terminates.
130+
cancelled = true;
131+
break;
132+
}
133+
134+
// The chain terminated without dispatching a redirect (a `true` cancel or a clean proceed),
135+
// so reset the depth for the next, unrelated navigation.
136+
redirectDepth = 0;
137+
138+
return cancelled ? false : undefined;
76139
});
77140

78141
const afterRouteMiddleware: NavigationHookAfter[] = [...(options?.afterRouteCallbacks ?? [])];
@@ -99,6 +162,7 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
99162
normalizedRouteToSpecificRoute,
100163

101164
goToRoute,
165+
replaceRoute,
102166
goToCreatePage: (name) => goToRoute(`${name}${CREATE_PAGE_NAME}`),
103167
goToOverviewPage: (name) => goToRoute(`${name}${OVERVIEW_PAGE_NAME}`),
104168
goToEditPage: (name, id) => goToRoute(`${name}${EDIT_PAGE_NAME}`, id),
@@ -160,7 +224,7 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
160224
}
161225
},
162226

163-
RouterView: createRouterView(currentRouteRef),
227+
RouterView: createRouterView(currentRouteRef, options?.notFoundComponent),
164228
RouterLink: createRouterLink(getUrlForRouteName, goToRoute),
165229
};
166230
};

0 commit comments

Comments
 (0)