Skip to content

Commit 7891355

Browse files
committed
fix(experimental)!: query params are optional by default
BREAKING CHANGE: Pass `required: true` to their definition to make them required and _miss a match_ if not provided in a location.
1 parent 74da87c commit 7891355

File tree

9 files changed

+323
-39
lines changed

9 files changed

+323
-39
lines changed

packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -270,25 +270,14 @@ describe('MatcherPatternQueryParam', () => {
270270
})
271271
})
272272

273-
it('throws if a required param is missing and no default', () => {
273+
it('returns undefined for missing optional param without default', () => {
274274
const matcher = new MatcherPatternQueryParam(
275275
'userId',
276276
'user_id',
277277
'value',
278278
PARAM_PARSER_DEFAULTS
279279
)
280-
expect(() => matcher.match({})).toThrow(MatchMiss)
281-
})
282-
283-
it('uses default when query param missing', () => {
284-
const matcher = new MatcherPatternQueryParam(
285-
'optional',
286-
'opt',
287-
'value',
288-
PARAM_PARSER_DEFAULTS,
289-
'fallback'
290-
)
291-
expect(matcher.match({})).toEqual({ optional: 'fallback' })
280+
expect(matcher.match({})).toEqual({ userId: undefined })
292281
})
293282

294283
it('uses function default when query param missing', () => {
@@ -314,6 +303,103 @@ describe('MatcherPatternQueryParam', () => {
314303
})
315304
})
316305

306+
describe('required parameter', () => {
307+
it('throws MatchMiss for required: true when param is missing', () => {
308+
const matcher = new MatcherPatternQueryParam(
309+
'requiredParam',
310+
'req',
311+
'value',
312+
PARAM_PARSER_DEFAULTS,
313+
undefined,
314+
true
315+
)
316+
expect(() => matcher.match({})).toThrow(MatchMiss)
317+
})
318+
319+
it('uses actual value when required: true and param exists', () => {
320+
const matcher = new MatcherPatternQueryParam(
321+
'requiredParam',
322+
'req',
323+
'value',
324+
PARAM_PARSER_DEFAULTS,
325+
undefined,
326+
true
327+
)
328+
expect(matcher.match({ req: 'value' })).toEqual({
329+
requiredParam: 'value',
330+
})
331+
})
332+
333+
it('uses default over required (default takes precedence)', () => {
334+
const matcher = new MatcherPatternQueryParam(
335+
'param',
336+
'p',
337+
'value',
338+
PARAM_PARSER_DEFAULTS,
339+
'default',
340+
true
341+
)
342+
// Even with required: true, default should be used if param is missing
343+
expect(matcher.match({})).toEqual({ param: 'default' })
344+
})
345+
346+
it('throws MatchMiss for required: true with invalid parser value', () => {
347+
const matcher = new MatcherPatternQueryParam(
348+
'count',
349+
'c',
350+
'value',
351+
PARAM_PARSER_INT,
352+
undefined,
353+
true
354+
)
355+
// Parser throws on invalid value, should propagate
356+
expect(() => matcher.match({ c: 'invalid' })).toThrow(MatchMiss)
357+
})
358+
359+
it('returns empty array for missing optional param with array format', () => {
360+
const matcher = new MatcherPatternQueryParam(
361+
'items',
362+
'item',
363+
'array',
364+
PARAM_PARSER_DEFAULTS,
365+
undefined,
366+
false
367+
)
368+
// Array format with missing param returns [], not undefined
369+
expect(matcher.match({})).toEqual({ items: [] })
370+
})
371+
372+
it('returns empty array for required: true with array format (parsed value is [])', () => {
373+
const matcher = new MatcherPatternQueryParam(
374+
'items',
375+
'item',
376+
'array',
377+
PARAM_PARSER_DEFAULTS,
378+
undefined,
379+
true
380+
)
381+
// Array format normalizes missing query param to [] *before* the parser runs.
382+
// Since [] is a valid parsed value (not undefined), required: true doesn't trigger.
383+
// This is expected behavior - array format treats missing as "empty array".
384+
expect(matcher.match({})).toEqual({ items: [] })
385+
})
386+
387+
it('handles null value differently from missing value with required: true', () => {
388+
const matcher = new MatcherPatternQueryParam(
389+
'param',
390+
'p',
391+
'value',
392+
PARAM_PARSER_DEFAULTS,
393+
undefined,
394+
true
395+
)
396+
// null is a valid value, not missing
397+
expect(matcher.match({ p: null })).toEqual({ param: null })
398+
// missing throws
399+
expect(() => matcher.match({})).toThrow(MatchMiss)
400+
})
401+
})
402+
317403
describe('edge cases', () => {
318404
it('handles empty array', () => {
319405
const matcher = new MatcherPatternQueryParam(
@@ -377,17 +463,6 @@ describe('MatcherPatternQueryParam', () => {
377463
values: ['a', null, 'b'],
378464
})
379465
})
380-
381-
it('handles undefined query param with default', () => {
382-
const matcher = new MatcherPatternQueryParam(
383-
'missing',
384-
'miss',
385-
'value',
386-
PARAM_PARSER_DEFAULTS,
387-
'default'
388-
)
389-
expect(matcher.match({ other: 'value' })).toEqual({ missing: 'default' })
390-
})
391466
})
392467

393468
it('should work without parser parameter', () => {

packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { toValue } from 'vue'
2-
import {
2+
import type {
33
EmptyParams,
44
MatcherParamsFormatted,
55
MatcherPattern,
66
MatcherQueryParams,
77
MatcherQueryParamsValue,
88
} from './matcher-pattern'
9-
import { ParamParser, PARAM_PARSER_DEFAULTS } from './param-parsers'
9+
import { type ParamParser, PARAM_PARSER_DEFAULTS } from './param-parsers'
1010
import { miss } from './errors'
1111

1212
/**
@@ -29,7 +29,8 @@ export class MatcherPatternQueryParam<
2929
private queryKey: string,
3030
private format: 'value' | 'array',
3131
private parser: ParamParser<T> = {},
32-
private defaultValue?: (() => T) | T
32+
private defaultValue?: (() => T) | T,
33+
private required?: boolean
3334
) {}
3435

3536
match(query: MatcherQueryParams): Record<ParamName, T> {
@@ -92,10 +93,14 @@ export class MatcherPatternQueryParam<
9293
// otherwise, use the default value. This allows parsers to return undefined
9394
// when they want to possibly fallback to the default value
9495
if (value === undefined) {
95-
if (this.defaultValue === undefined) {
96+
if (this.defaultValue !== undefined) {
97+
// Has default: use it
98+
value = toValue(this.defaultValue)
99+
} else if (this.required) {
100+
// Required but no default and no value: throw
96101
throw miss()
97102
}
98-
value = toValue(this.defaultValue)
103+
// else: optional param without default, value stays undefined
99104
}
100105

101106
return {

packages/router/src/experimental/runtime.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export interface DefinePageQueryParamOptions<T = unknown> {
9595
/**
9696
* Default value if the query parameter is missing or if the match fails
9797
* (e.g. a invalid number is passed to the int param parser). If not provided
98-
* and the param parser throws, the route will not match.
98+
* and the param is not required, the route will match with undefined.
9999
*/
100100
default?: (() => T) | T
101101

@@ -108,6 +108,14 @@ export interface DefinePageQueryParamOptions<T = unknown> {
108108
* @default 'value'
109109
*/
110110
format?: 'value' | 'array'
111+
112+
/**
113+
* Whether this query parameter is required. If true and the parameter is
114+
* missing (and no default is provided), the route will not match.
115+
*
116+
* @default false
117+
*/
118+
required?: boolean
111119
}
112120

113121
/**

packages/router/src/unplugin/codegen/generateRouteMap.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,158 @@ describe('generateRouteNamedMap', () => {
811811
}"
812812
`)
813813
})
814+
815+
describe('experimental param parsers with query params', () => {
816+
const OPTIONS_WITH_PARSERS = resolveOptions({
817+
experimental: { paramParsers: true },
818+
})
819+
820+
it('includes undefined for optional query params without default', () => {
821+
const tree = new PrefixTree(OPTIONS_WITH_PARSERS)
822+
const node = tree.insert('search', 'search.vue')
823+
node.value.setEditOverride('params', {
824+
query: { q: {} },
825+
})
826+
expect(
827+
formatExports(
828+
generateRouteNamedMap(tree, OPTIONS_WITH_PARSERS, new Map())
829+
)
830+
).toMatchInlineSnapshot(`
831+
"export interface RouteNamedMap {
832+
'/search': RouteRecordInfo<
833+
'/search',
834+
'/search',
835+
{ q?: string },
836+
{ q: string | undefined },
837+
| never
838+
>,
839+
}"
840+
`)
841+
})
842+
843+
it('does not include undefined for query params with default', () => {
844+
const tree = new PrefixTree(OPTIONS_WITH_PARSERS)
845+
const node = tree.insert('search', 'search.vue')
846+
node.value.setEditOverride('params', {
847+
query: { limit: { parser: 'int', default: '10' } },
848+
})
849+
expect(
850+
formatExports(
851+
generateRouteNamedMap(tree, OPTIONS_WITH_PARSERS, new Map())
852+
)
853+
).toMatchInlineSnapshot(`
854+
"export interface RouteNamedMap {
855+
'/search': RouteRecordInfo<
856+
'/search',
857+
'/search',
858+
{ limit?: number },
859+
{ limit: number },
860+
| never
861+
>,
862+
}"
863+
`)
864+
})
865+
866+
it('does not include undefined for required query params', () => {
867+
const tree = new PrefixTree(OPTIONS_WITH_PARSERS)
868+
const node = tree.insert('search', 'search.vue')
869+
node.value.setEditOverride('params', {
870+
query: { q: { required: true } },
871+
})
872+
expect(
873+
formatExports(
874+
generateRouteNamedMap(tree, OPTIONS_WITH_PARSERS, new Map())
875+
)
876+
).toMatchInlineSnapshot(`
877+
"export interface RouteNamedMap {
878+
'/search': RouteRecordInfo<
879+
'/search',
880+
'/search',
881+
{ q: string },
882+
{ q: string },
883+
| never
884+
>,
885+
}"
886+
`)
887+
})
888+
889+
it('includes undefined for default: undefined', () => {
890+
const tree = new PrefixTree(OPTIONS_WITH_PARSERS)
891+
const node = tree.insert('search', 'search.vue')
892+
node.value.setEditOverride('params', {
893+
query: { q: { default: 'undefined' } },
894+
})
895+
expect(
896+
formatExports(
897+
generateRouteNamedMap(tree, OPTIONS_WITH_PARSERS, new Map())
898+
)
899+
).toMatchInlineSnapshot(`
900+
"export interface RouteNamedMap {
901+
'/search': RouteRecordInfo<
902+
'/search',
903+
'/search',
904+
{ q?: string },
905+
{ q: string | undefined },
906+
| never
907+
>,
908+
}"
909+
`)
910+
})
911+
912+
it('handles mixed optional and required query params', () => {
913+
const tree = new PrefixTree(OPTIONS_WITH_PARSERS)
914+
const node = tree.insert('search', 'search.vue')
915+
node.value.setEditOverride('params', {
916+
query: {
917+
q: { required: true },
918+
page: { parser: 'int', default: '1' },
919+
sort: {},
920+
filter: { parser: 'int' },
921+
},
922+
})
923+
expect(
924+
formatExports(
925+
generateRouteNamedMap(tree, OPTIONS_WITH_PARSERS, new Map())
926+
)
927+
).toMatchInlineSnapshot(`
928+
"export interface RouteNamedMap {
929+
'/search': RouteRecordInfo<
930+
'/search',
931+
'/search',
932+
{ q: string, page?: number, sort?: string, filter?: number },
933+
{ q: string, page: number, sort: string | undefined, filter: number | undefined },
934+
| never
935+
>,
936+
}"
937+
`)
938+
})
939+
940+
it('handles array format query params', () => {
941+
const tree = new PrefixTree(OPTIONS_WITH_PARSERS)
942+
const node = tree.insert('search', 'search.vue')
943+
node.value.setEditOverride('params', {
944+
query: {
945+
tags: { format: 'array' },
946+
ids: { parser: 'int', format: 'array', required: true },
947+
},
948+
})
949+
expect(
950+
formatExports(
951+
generateRouteNamedMap(tree, OPTIONS_WITH_PARSERS, new Map())
952+
)
953+
).toMatchInlineSnapshot(`
954+
"export interface RouteNamedMap {
955+
'/search': RouteRecordInfo<
956+
'/search',
957+
'/search',
958+
{ tags?: string[], ids: number[] },
959+
{ tags: string[] | undefined, ids: number[] },
960+
| never
961+
>,
962+
}"
963+
`)
964+
})
965+
})
814966
})
815967

816968
/**

0 commit comments

Comments
 (0)