Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 52 additions & 18 deletions docs/packages/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,6 +215,14 @@ import {router} from '@/services';
</template>
```

`RouterLink` forwards consumer-set attributes (`class`, `style`, `data-*`, `aria-*`, …) onto the rendered `<a>`, so a styled link keeps its styling:

```vue
<router.RouterLink :to="{name: 'users.overview'}" class="nav-link" aria-current="page">Users</router.RouterLink>
```

The computed `href` and the navigation `onClick` stay authoritative — a fallthrough `href` attribute cannot override them.

## URL Generation

Generate URLs without navigating:
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions packages/router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
Comment thread
Goosterhof marked this conversation as resolved.
2 changes: 1 addition & 1 deletion packages/router/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 11 additions & 5 deletions packages/router/src/components.ts
Original file line number Diff line number Diff line change
@@ -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';

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

export const createRouterView = (currentRouteRef: Ref<RouteLocationNormalizedLoaded>): RouterViewComponent =>
export const createRouterView = (
currentRouteRef: Ref<RouteLocationNormalizedLoaded>,
notFoundComponent?: RouteComponent,
): RouterViewComponent =>
defineComponent<{depth?: number}>(
({depth = 0}) => {
const component = computed(() => {
Expand All @@ -24,7 +27,7 @@ export const createRouterView = (currentRouteRef: Ref<RouteLocationNormalizedLoa
});

return () => {
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)});
};
Expand All @@ -39,11 +42,14 @@ export const createRouterLink = <Routes extends RouteRecordRaw[]>(
goToRoute: RouterService<Routes>['goToRoute'],
): RouterLinkComponent<Routes> =>
defineComponent<{to: {name: RouteName<Routes>; 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;
Expand All @@ -56,5 +62,5 @@ export const createRouterLink = <Routes extends RouteRecordRaw[]>(
),
// https://vuejs.org/api/general.html#function-signature
// manual runtime props declaration is currently still needed
{props: ['to']},
{props: ['to'], inheritAttrs: false},
);
2 changes: 2 additions & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type {
OverviewRouteName,
EditRouteName,
ShowRouteName,
MiddlewareRedirect,
BeforeRouteMiddlewareResult,
BeforeRouteMiddleware,
UnregisterMiddleware,
RouterViewComponent,
Expand Down
78 changes: 71 additions & 7 deletions packages/router/src/router.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -45,14 +45,27 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
return Object.fromEntries(Object.entries(params).filter(([key]) => targetPath.includes(`:${key}`)));
};

const goToRoute: RouterService<Routes>['goToRoute'] = async (name, id, query, parentId) => {
const buildRouteLocation = (
name: RouteName<Routes>,
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<Routes>['goToRoute'] = async (name, id, query, parentId) => {
await router.push(buildRouteLocation(name, id, query, parentId));
};

const replaceRoute: RouterService<Routes>['replaceRoute'] = async (name, id, query, parentId) => {
await router.replace(buildRouteLocation(name, id, query, parentId));
};

const normalizedRouteToSpecificRoute: RouterService<Routes>['normalizedRouteToSpecificRoute'] = (route) => {
Expand All @@ -66,13 +79,63 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
const getUrlForRouteName: RouterService<Routes>['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<Routes>[] = [];
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) {
Comment thread
jasperboerhof marked this conversation as resolved.
Comment thread
Goosterhof marked this conversation as resolved.
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;
Comment thread
Goosterhof marked this conversation as resolved.

return cancelled ? false : undefined;
});

const afterRouteMiddleware: NavigationHookAfter[] = [...(options?.afterRouteCallbacks ?? [])];
Expand All @@ -99,6 +162,7 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
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),
Expand Down Expand Up @@ -160,7 +224,7 @@ export const createRouterService = <Routes extends RouteRecordRaw[]>(
}
},

RouterView: createRouterView(currentRouteRef),
RouterView: createRouterView(currentRouteRef, options?.notFoundComponent),
RouterLink: createRouterLink(getUrlForRouteName, goToRoute),
};
};
Loading