Visitar URL original
feat(volar): type defineProps() with route params when definePage pro… · vuejs/router@ca85aae · GitHub
Skip to content

Commit ca85aae

Browse files
committed
feat(volar): type defineProps() with route params when definePage props is true
The sfc-typed-router plugin adds the params of the routes of the page to the type argument of defineProps(), as an intersection with the types that the user declares.
1 parent 2f96b61 commit ca85aae

5 files changed

Lines changed: 149 additions & 0 deletions

File tree

‎packages/docs/experimental/router-resolver.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ definePage({
168168

169169
With the `sfc-typed-router` Volar plugin, TypeScript checks `props` against the params of the route and against the props of the page component. In the example above, `props: true` gives an error if the component declares a required prop that the params do not supply, or if the types do not match (e.g. `userId: string` with the `int` parser).
170170

171+
With `props: true`, the plugin also adds the params of the route to the type of `defineProps()`, so `defineProps<{ extra?: string }>()` is typed as `defineProps<RouteParams & { extra?: string }>()`. This only changes the types.
172+
171173
## With Data Loaders
172174

173175
If you use [Data Loaders](../data-loaders/), install the plugin **before** the router:

‎packages/router/src/experimental/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export {
8989
type DefinePageQueryParamOptions,
9090
type DefinePageQueryParamOptionsAny as _DefinePageQueryParamOptionsAny,
9191
type PathParamNamesForFilePath as _PathParamNamesForFilePath,
92+
type RoutePropsForFilePath as _RoutePropsForFilePath,
9293
} from './runtime'
9394

9495
// Data loaders exports

‎packages/router/src/experimental/runtime.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ export type RouteLocationForFilePath<FilePath extends string> = [
7878
? RouteLocationNormalizedLoadedGeneric
7979
: RouteLocationNormalizedLoaded<RouteNamesForFilePath<FilePath>>
8080

81+
/**
82+
* Props that `definePage({ props: true })` passes to a page component: the
83+
* params of the routes that render the page file. The `sfc-typed-router`
84+
* Volar plugin injects it into `defineProps()`.
85+
*
86+
* @internal
87+
*/
88+
export type RoutePropsForFilePath<FilePath extends string> =
89+
RouteLocationForFilePath<FilePath>['params']
90+
8191
/**
8292
* Merges route records.
8393
*

‎packages/router/src/volar/entries/sfc-typed-router.spec.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,75 @@ describe('sfc-typed-router volar plugin', () => {
8989
const code = `definePage<'a.vue'>({})`
9090
expect(transform(code)).toBe(code)
9191
})
92+
93+
describe('defineProps() with `props: true`', () => {
94+
const ROUTE_PROPS = `import('vue-router/experimental')._RoutePropsForFilePath<'src/pages/users/sub-[first]-[second].vue'>`
95+
96+
/** Gets the `defineProps()` statement out of the transformed code. */
97+
function transformProps(code: string, opts?: { lang?: string }) {
98+
return transform(code, opts).split('\n')[0]
99+
}
100+
101+
it('adds the route props as the type argument', () => {
102+
expect(
103+
transformProps(
104+
`const props = defineProps()\ndefinePage({ props: true })`
105+
)
106+
).toBe(`const props = defineProps<${ROUTE_PROPS}>()`)
107+
})
108+
109+
it('intersects the route props with an inline type argument', () => {
110+
expect(
111+
transformProps(
112+
`const props = defineProps<{ myProp: string; other: number }>()\ndefinePage({ props: true })`
113+
)
114+
).toBe(
115+
`const props = defineProps<${ROUTE_PROPS} & ({ myProp: string; other: number })>()`
116+
)
117+
})
118+
119+
it('intersects the route props with combined type references', () => {
120+
expect(
121+
transformProps(
122+
`const props = defineProps<A & Omit<B, 'c'> | C>()\ndefinePage({ props: true })`
123+
)
124+
).toBe(
125+
`const props = defineProps<${ROUTE_PROPS} & (A & Omit<B, 'c'> | C)>()`
126+
)
127+
})
128+
129+
it('works with withDefaults() and definePage() first', () => {
130+
expect(
131+
transform(
132+
`definePage({ props: true })\nconst props = withDefaults(defineProps<Props>(), { a: 1 })`
133+
).split('\n')[1]
134+
).toBe(
135+
`const props = withDefaults(defineProps<${ROUTE_PROPS} & (Props)>(), { a: 1 })`
136+
)
137+
})
138+
139+
it('does nothing without `props: true`', () => {
140+
for (const code of [
141+
`const props = defineProps()\ndefinePage({ props: false })`,
142+
`const props = defineProps()\ndefinePage({ props: to => to.params })`,
143+
`const props = defineProps()\ndefinePage({})`,
144+
`const props = defineProps()`,
145+
]) {
146+
expect(transformProps(code)).toBe(code.split('\n')[0])
147+
}
148+
})
149+
150+
it('leaves runtime props declarations untouched', () => {
151+
expect(
152+
transformProps(
153+
`const props = defineProps(['a'])\ndefinePage({ props: true })`
154+
)
155+
).toBe(`const props = defineProps(['a'])`)
156+
})
157+
158+
it('does nothing in a js script', () => {
159+
const code = `const props = defineProps()\ndefinePage({ props: true })`
160+
expect(transform(code, { lang: 'js' })).toBe(code)
161+
})
162+
})
92163
})

‎packages/router/src/volar/entries/sfc-typed-router.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,41 @@ const plugin: VueLanguagePlugin<{ options?: { rootDir?: string } }> = ({
6666
.replace(/'/g, "\\'")
6767
const definePageTypeParams = `<'${escapedFilePath}', typeof import('${escapedSelfImport}').default>`
6868

69+
const routePropsType = `import('vue-router/experimental')._RoutePropsForFilePath<'${escapedFilePath}'>`
70+
71+
// `definePage()` can come after `defineProps()`
72+
const hasPropsTrue =
73+
!!sfc.scriptSetup &&
74+
!sfc.scriptSetup.lang.startsWith('js') &&
75+
sfc.scriptSetup.ast.statements.some(isDefinePageWithPropsTrue)
76+
6977
if (sfc.scriptSetup) {
7078
visit(sfc.scriptSetup.ast)
7179
}
7280

81+
function isDefinePageWithPropsTrue(statement: ts.Statement) {
82+
if (
83+
!ts.isExpressionStatement(statement) ||
84+
!ts.isCallExpression(statement.expression) ||
85+
!ts.isIdentifier(statement.expression.expression) ||
86+
ts.idText(statement.expression.expression) !== 'definePage'
87+
) {
88+
return false
89+
}
90+
const [arg] = statement.expression.arguments
91+
return (
92+
!!arg &&
93+
ts.isObjectLiteralExpression(arg) &&
94+
arg.properties.some(
95+
prop =>
96+
ts.isPropertyAssignment(prop) &&
97+
ts.isIdentifier(prop.name) &&
98+
ts.idText(prop.name) === 'props' &&
99+
prop.initializer.kind === ts.SyntaxKind.TrueKeyword
100+
)
101+
)
102+
}
103+
73104
function visit(node: ts.Node) {
74105
if (
75106
ts.isCallExpression(node) &&
@@ -139,6 +170,40 @@ const plugin: VueLanguagePlugin<{ options?: { rootDir?: string } }> = ({
139170
node.expression.end,
140171
definePageTypeParams
141172
)
173+
} else if (
174+
hasPropsTrue &&
175+
ts.isCallExpression(node) &&
176+
ts.isIdentifier(node.expression) &&
177+
ts.idText(node.expression) === 'defineProps' &&
178+
!node.arguments.length
179+
) {
180+
// `props: true` passes the route params as props
181+
const [typeArg] = node.typeArguments ?? []
182+
if (typeArg) {
183+
const { start } = getStartEnd(typeArg, sfc.scriptSetup!.ast)
184+
replaceSourceRange(
185+
embeddedCode.content,
186+
sfc.scriptSetup!.name,
187+
start,
188+
start,
189+
`${routePropsType} & (`
190+
)
191+
replaceSourceRange(
192+
embeddedCode.content,
193+
sfc.scriptSetup!.name,
194+
typeArg.end,
195+
typeArg.end,
196+
`)`
197+
)
198+
} else {
199+
replaceSourceRange(
200+
embeddedCode.content,
201+
sfc.scriptSetup!.name,
202+
node.expression.end,
203+
node.expression.end,
204+
`<${routePropsType}>`
205+
)
206+
}
142207
} else {
143208
ts.forEachChild(node, visit)
144209
}

0 commit comments

Comments
 (0)