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
17 changes: 17 additions & 0 deletions docs/packages/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ const router = createRouterService(routes);

Omit a component to skip that route — `projects` above has no `show` page.

### Two Levels Deep

A route tree is at most two levels deep: a top-level route and its direct `children`. `createRouterService` throws at construction when a child route has `children` of its own, naming that route. vue-router would accept the deeper tree, but fs-router's route lookup only sees two levels, so the third level would fail at navigation time instead.

Nest deeper in the **path**, not in the tree. `createNestedCrudRoutes` does exactly that:

```typescript
// ✗ three levels — throws at construction
{path: '/projects', component: ProjectsLayout, children: [
{path: ':parentId/issues', component: IssuesLayout, children: [/* … */]},
]}

// ✓ two levels — the parent segment lives in the path
createNestedCrudRoutes({parent: 'projects', child: 'issues'}, 'project-issues', IssuesLayout, {/* … */});
// → /projects/:parentId/issues, /projects/:parentId/issues/:id, …
```

### Type-Safe Navigation

```typescript
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

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

6 changes: 6 additions & 0 deletions packages/router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @script-development/fs-router

## 0.3.2 — 2026-10-07

### Patch Changes

- **A route tree deeper than two levels is refused at construction (SH-0199).** fs-router's route lookup sees a top-level route and its direct `children` and nothing below, while vue-router accepts any depth. A third-level route therefore matched, reached fs-router's `beforeEach`, and threw `<path> is an unknown route` — at navigation time, and only on that route, so the limit stayed invisible until someone visited it. `createRouterService` now throws when any child route has `children` of its own, with a message naming that route and the two-level limit. An empty `children` array adds no route and is accepted. Two-level trees are unchanged. Nest deeper in the path instead (`/parent/:parentId/child`, which is what `createNestedCrudRoutes` builds); see "Two Levels Deep" in the docs. No known consumer has a third level, so no consumer has to change anything.

## 0.3.1 — 2026-10-03

### Patch Changes
Expand Down
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.3.1",
"version": "0.3.2",
"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
22 changes: 22 additions & 0 deletions packages/router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,32 @@ import type {BeforeRouteMiddleware, MiddlewareRedirect, RouteName, RouterService
import {createRouterLink, createRouterView} from './components';
import {CREATE_PAGE_NAME, EDIT_PAGE_NAME, OVERVIEW_PAGE_NAME, SHOW_PAGE_NAME} from './routes';

// The lookup `createRouterService` builds sees two levels: a top-level route and its direct
// children. vue-router accepts a third, so a third-level route would match, reach fs-router's
// `beforeEach`, and throw "unknown route" there — at navigation time, and only on that route.
// Refusing the tree at construction turns that silent limit into a loud one at startup.
// An empty `children` array adds no route, so it is not a third level.
const assertTwoLevelRouteTree = (routes: readonly RouteRecordRaw[]): void => {
for (const route of routes) {
for (const child of route.children ?? []) {
if (!child.children?.length) continue;

const label = String(child.name ?? child.path);
throw new Error(
`fs-router: route "${label}" has children of its own, but fs-router supports two levels only ` +
'(a route and its children). Compose the deeper level into the path instead, ' +
'e.g. "/parent/:parentId/child".',
);
}
}
};

export const createRouterService = <Routes extends RouteRecordRaw[]>(
routes: Routes,
options?: RouterServiceOptions,
): RouterService<Routes> => {
assertTwoLevelRouteTree(routes);

const router = createRouter({history: createWebHistory(options?.base), routes});

const flattenedRoutes = routes
Expand Down
30 changes: 11 additions & 19 deletions packages/router/tests/components.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,28 +687,20 @@ describe('createRouterView', () => {
window.history.replaceState({}, '', '/');
});

it('should never read a route vue-router matched as a miss, at any depth', async () => {
// Arrange — the miss is vue-router's verdict (nothing matched), never fs-router's two-level
// flattened lookup. A matched grandchild must therefore take the lookup, not the miss path
// that skips middleware. The lookup still rejects at that depth, as it did before WR-1160;
// landing on the leaf instead would mean it slipped past the cancelling middleware.
window.history.replaceState({}, '', '/deep/inner/leaf');
it('should never read a route vue-router matched as a miss, even one the flattened lookup cannot find', async () => {
// Arrange — the miss is vue-router's verdict (nothing matched), never fs-router's flattened
// lookup. The two disagree on a parent route visited at its own path: vue-router matches the
// parent record, while the lookup keeps only its children. Such a hop must take the lookup,
// not the miss path that skips middleware, and the lookup rejects it as it did before
// WR-1160; landing on the parent instead would mean it slipped past the cancelling
// middleware. (A third level used to be the other disagreement; construction now refuses it.)
window.history.replaceState({}, '', '/deep');
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const layout = defineComponent({render: () => h('div', 'layout')});
const service = createRouterService([
{path: '/', name: 'home', component: TestPage},
{
path: '/deep',
component: layout,
children: [
{
path: 'inner',
component: layout,
children: [{path: 'leaf', name: 'deep.leaf', component: TestPage}],
},
],
},
{path: '/deep', component: layout, children: [{path: 'leaf', name: 'deep.leaf', component: TestPage}]},
]);
service.registerBeforeRouteMiddleware(() => true);

Expand All @@ -720,8 +712,8 @@ describe('createRouterView', () => {
await flushPromises();

// Assert
expect(installed).toBe('Error: /deep/inner/leaf is an unknown route');
expect(service.currentRouteRef.value.name).not.toBe('deep.leaf');
expect(installed).toBe('Error: /deep is an unknown route');
expect(service.currentRouteRef.value.path).not.toBe('/deep');

consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
Expand Down
70 changes: 70 additions & 0 deletions packages/router/tests/router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,76 @@ describe('router service', () => {
expect(service).toHaveProperty('RouterView');
expect(service).toHaveProperty('RouterLink');
});

it('should throw at construction, naming the route, when a child route has children of its own', () => {
// Arrange
const routes: RouteRecordRaw[] = [
{
path: '/',
component: TestLayout,
children: [
{
path: 'settings',
name: 'settings',
component: TestLayout,
children: [{path: 'profile', name: 'settings.profile', component: TestPage}],
},
],
},
];

// Act & Assert
expect(() => createRouterService(routes)).toThrow(
'fs-router: route "settings" has children of its own, but fs-router supports two levels only ' +
'(a route and its children). Compose the deeper level into the path instead, ' +
'e.g. "/parent/:parentId/child".',
);
});

it('should name an unnamed third-level parent by its path', () => {
// Arrange
const routes: RouteRecordRaw[] = [
{
path: '/',
component: TestLayout,
children: [
{
path: 'account',
component: TestLayout,
children: [{path: 'profile', name: 'account.profile', component: TestPage}],
},
],
},
];

// Act & Assert
expect(() => createRouterService(routes)).toThrow('fs-router: route "account" has children of its own');
});

it('should not treat an empty children array on a child route as a third level', () => {
// Arrange
const routes: RouteRecordRaw[] = [
{
path: '/',
component: TestLayout,
children: [{path: 'settings', name: 'settings', component: TestPage, children: []}],
},
];

// Act & Assert
expect(() => createRouterService(routes)).not.toThrow();
});

it('should construct and navigate a two-level tree', async () => {
// Arrange
const service = createRouterService(createTestRoutes());

// Act
await service.goToRoute('items.create');

// Assert
expect(service.currentRouteRef.value.name).toBe('items.create');
});
});

describe('install', () => {
Expand Down
Loading