diff --git a/docs/packages/router.md b/docs/packages/router.md index 12ac7bb..11c08cd 100644 --- a/docs/packages/router.md +++ b/docs/packages/router.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 74e9165..a785bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7674,9 +7674,9 @@ } }, "node_modules/shell-quote": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", - "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { @@ -7848,9 +7848,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9354,7 +9354,7 @@ }, "packages/router": { "name": "@script-development/fs-router", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "devDependencies": { "@vue/test-utils": "^2.5.1", diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md index e0cb9ee..680bcad 100644 --- a/packages/router/CHANGELOG.md +++ b/packages/router/CHANGELOG.md @@ -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 ` 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 diff --git a/packages/router/package.json b/packages/router/package.json index 2d0c2b1..4f544f3 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -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", diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 05f71ac..0d9a5b5 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -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: Routes, options?: RouterServiceOptions, ): RouterService => { + assertTwoLevelRouteTree(routes); + const router = createRouter({history: createWebHistory(options?.base), routes}); const flattenedRoutes = routes diff --git a/packages/router/tests/components.spec.ts b/packages/router/tests/components.spec.ts index a0c6853..7ee9b5a 100644 --- a/packages/router/tests/components.spec.ts +++ b/packages/router/tests/components.spec.ts @@ -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); @@ -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(); diff --git a/packages/router/tests/router.spec.ts b/packages/router/tests/router.spec.ts index f290a38..f6d648f 100644 --- a/packages/router/tests/router.spec.ts +++ b/packages/router/tests/router.spec.ts @@ -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', () => {