Skip to content

Commit 8564ea1

Browse files
authored
fix: route argument narrowing with experimental.typedPages (#4024)
1 parent 89cf5c1 commit 8564ea1

13 files changed

Lines changed: 331 additions & 85 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"lint:fix": "eslint . --cache --fix",
6565
"lint:knip": "knip --workspace .",
6666
"test": "nuxt-module-build prepare && pnpm test:types && pnpm test:unit && pnpm test:e2e",
67-
"test:types": "tsc --noEmit",
67+
"test:types": "tsc --noEmit && pnpm --filter './specs/fixtures/**' test:types",
6868
"test:unit": "vitest run test -c vitest.config.test.ts",
6969
"test:e2e": "vitest run specs"
7070
},

pnpm-lock.yaml

Lines changed: 114 additions & 69 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

renovate.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
{
2222
"groupName": "all non-major dependencies",
2323
"groupSlug": "all-minor-patch",
24-
"matchFileNames": ["package.json"],
24+
"matchFileNames": ["package.json", "specs/fixtures/**/package.json"],
2525
"matchUpdateTypes": ["minor", "patch"],
2626
"lockFileMaintenance": {
2727
"enabled": true,

specs/experimental/typed_pages.spec.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,34 @@ await setup({
1818

1919
// @see reference https://github.com/nuxt/nuxt/blob/c55db28542d29fd912889985780af70b4bb2ee2e/test/typed-router.test.ts
2020
describe('`experimental.typedPages` undefined or enabled', async () => {
21+
const buildDir = resolve(rootDir, '.nuxt/___experimental_typed_pages_spec_ts')
22+
2123
test('generates route types', async () => {
22-
const typedRouterDtsFile = resolve(rootDir, '.nuxt/___experimental_typed_pages_spec_ts/types/typed-router-i18n.d.ts')
23-
const typedRouterDts = readFileSync(typedRouterDtsFile, 'utf-8')
24+
const typedRouterDts = readFileSync(resolve(buildDir, 'types/typed-router-i18n.d.ts'), 'utf-8')
2425

2526
// Check for the interface definition
2627
expect(typedRouterDts).toMatch(/export interface RouteNamedMapI18n \{/)
2728
// Check for the route definition (accommodates both single-line and multi-line formatting)
2829
expect(typedRouterDts).toMatch(/'category-slug':\s*RouteRecordInfo<\s*'category-slug',\s*'\/category\/:slug\(\)',\s*\{\s*slug:\s*ParamValue<true>\s*\},\s*\{\s*slug:\s*ParamValue<false>\s*\}/)
30+
31+
// All occurrences must be renamed, or the interface merges with the one
32+
// from Nuxt's typed-router.d.ts and pollutes `RouteMap` (#3962)
33+
expect(typedRouterDts).not.toMatch(/\bRouteNamedMap\b/)
34+
expect(typedRouterDts).not.toMatch(/\b_RouteFileInfoMap\b/)
35+
})
36+
37+
test('vue-router augmentation resolves its type references', async () => {
38+
const pluginDts = readFileSync(resolve(buildDir, 'types/i18n-plugin.d.ts'), 'utf-8')
39+
40+
// Names used inside the `declare module 'vue-router'` block resolve against the file
41+
// scope, not the augmented module; without these imports `skipLibCheck` silently turns
42+
// the augmentation types into `any`, disabling route narrowing entirely (#3962)
43+
for (const name of pluginDts.matchAll(/\b(TypesConfig|RouteMapGeneric|RouteLocation\w*(?:Generic|TypedList))\b/g)) {
44+
expect(pluginDts).toMatch(new RegExp(`import type \\{[^}]*\\b${name[1]}\\b[^}]*\\} from 'vue-router'`, 's'))
45+
}
46+
// removed in vue-router v5
47+
expect(pluginDts).not.toContain('_LiteralUnion')
48+
49+
// narrowing itself is asserted by the `typed_routes` fixture, part of `test:types`
2950
})
3051
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<div>slug</div>
3+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<template>
2+
<div>about</div>
3+
</template>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<template>
2+
<div>
3+
index
4+
<NuxtLinkLocale :to="{ name: 'about' }">about</NuxtLinkLocale>
5+
<NuxtLinkLocale :to="{ name: 'slug', params: { slug: 'ok' } }">slug</NuxtLinkLocale>
6+
<NuxtLinkLocale :to="{ path: '/anything' }" locale="nl">path</NuxtLinkLocale>
7+
<NuxtLinkLocale to="about">name string</NuxtLinkLocale>
8+
<!-- @vue-expect-error invalid route name -->
9+
<NuxtLinkLocale :to="{ name: 'nope' }">invalid</NuxtLinkLocale>
10+
</div>
11+
</template>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Type-level assertions for route narrowing with `experimental.typedPages` (#3962).
3+
*
4+
* Never executed, only checked by this fixture's `test:types` script — an unused
5+
* `@ts-expect-error` directive fails the check. The `not.toBeAny()` assertions guard
6+
* against the vue-router augmentation silently resolving to `any` when it references
7+
* types without importing them (`skipLibCheck` swallows the resolution errors).
8+
*/
9+
import { describe, expectTypeOf, it } from 'vitest'
10+
import { useLocalePath, useLocaleRoute } from '#imports'
11+
import { useRoute, useRouter } from 'vue-router'
12+
import type { RouteLocationNamedI18n, RouteMapI18n } from 'vue-router'
13+
14+
describe('vue-router augmentation', () => {
15+
it('resolves generated route maps', () => {
16+
expectTypeOf<RouteMapI18n>().not.toBeAny()
17+
expectTypeOf<keyof RouteMapI18n>().toEqualTypeOf<'index' | 'about' | 'slug'>()
18+
expectTypeOf<RouteLocationNamedI18n>().not.toBeAny()
19+
})
20+
21+
it('holds only localized names in vue-router route map', () => {
22+
const router = useRouter()
23+
router.push({ name: 'slug___en', params: { slug: 'ok' } })
24+
// @ts-expect-error base name must not leak into vue-router's RouteMap
25+
router.push({ name: 'slug' })
26+
// @ts-expect-error invalid route name
27+
router.push({ name: 'nope' })
28+
})
29+
})
30+
31+
describe('useLocalePath', () => {
32+
const localePath = useLocalePath()
33+
34+
it('narrows route name strings', () => {
35+
localePath('about')
36+
localePath('about', 'nl')
37+
// @ts-expect-error invalid route name
38+
localePath('nope')
39+
})
40+
41+
it('narrows name and params of route objects', () => {
42+
localePath({ name: 'about' })
43+
localePath({ name: 'slug', params: { slug: 'ok' } })
44+
localePath({ name: 'about', query: { q: '1' }, hash: '#x' })
45+
// @ts-expect-error invalid route name
46+
localePath({ name: 'nope' })
47+
// @ts-expect-error wrong param type
48+
localePath({ name: 'slug', params: { slug: false } })
49+
})
50+
51+
it('accepts plain string path objects and resolved routes', () => {
52+
localePath({ path: '/anything', query: { q: '1' } })
53+
localePath(useRoute())
54+
})
55+
})
56+
57+
describe('useLocaleRoute', () => {
58+
const localeRoute = useLocaleRoute()
59+
60+
it('narrows name and params of route objects', () => {
61+
// @ts-expect-error invalid route name
62+
localeRoute({ name: 'nope' })
63+
// @ts-expect-error wrong param type
64+
localeRoute({ name: 'slug', params: { slug: false } })
65+
})
66+
67+
it('narrows the resolved route to the given name', () => {
68+
const resolved = localeRoute({ name: 'slug', params: { slug: 'ok' } })
69+
expectTypeOf(resolved).not.toBeAny()
70+
expectTypeOf(resolved!.name).toEqualTypeOf<'slug'>()
71+
expectTypeOf(resolved!.params).toEqualTypeOf<{ slug: string }>()
72+
})
73+
})
74+
75+
describe('`NuxtLinkLocale` `to` prop', () => {
76+
it('narrows name and params of route objects', () => {
77+
expectTypeOf<{ name: 'slug'; params: { slug: 'ok' } }>().toExtend<RouteLocationNamedI18n>()
78+
expectTypeOf<{ path: '/anything' }>().toExtend<RouteLocationNamedI18n>()
79+
expectTypeOf<'about'>().toExtend<RouteLocationNamedI18n>()
80+
expectTypeOf<{ name: 'nope' }>().not.toExtend<RouteLocationNamedI18n>()
81+
expectTypeOf<{ name: 'slug'; params: { slug: boolean } }>().not.toExtend<RouteLocationNamedI18n>()
82+
expectTypeOf<'nope'>().not.toExtend<RouteLocationNamedI18n>()
83+
})
84+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default defineNuxtConfig({
2+
modules: ['@nuxtjs/i18n'],
3+
4+
experimental: {
5+
typedPages: true,
6+
},
7+
8+
i18n: {
9+
defaultLocale: 'en',
10+
locales: ['en', 'nl'],
11+
strategy: 'prefix_except_default',
12+
detectBrowserLanguage: false,
13+
},
14+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "fixture-typed-routes",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"build": "nuxi build",
7+
"dev": "nuxi dev",
8+
"generate": "nuxi generate",
9+
"preview": "nuxi preview",
10+
"test:types": "nuxi prepare && vue-tsc -b --noEmit"
11+
},
12+
"devDependencies": {
13+
"@nuxtjs/i18n": "latest",
14+
"nuxt": "latest",
15+
"vue-tsc": "^3.1.9"
16+
}
17+
}

0 commit comments

Comments
 (0)