Skip to content

Commit f1fbc06

Browse files
authored
feat(experimental)!: normalize query values to arrays (#2824)
BREAKING CHANGE: in the new experimental router, all query values are arrays by default, to simplify type checks. Using param parsers still allow to extract one single value and are unchanged
1 parent 3a192ad commit f1fbc06

12 files changed

Lines changed: 179 additions & 103 deletions

File tree

‎packages/docs/experimental/param-parsers.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,11 @@ export const parser = defineParamParserRaw<Set<string>>({
147147

148148
Here is a table of the different meaningful combinations of return values from `set` and how the router treats them for path and query params:
149149

150-
| `set` returns | Path param | Query param |
151-
| -------------------- | ------------------------- | ---------------------------------------------------------------- |
152-
| `null` / `undefined` | param is omitted | param is omitted (`undefined`) or rendered empty (`null`, `?k=`) |
153-
| `string` | single segment (`/value`) | single entry (`?k=value`) |
154-
| `string[]` | repeatable (`/a/b/c`) | repeated entries (`?k=a&k=b`) |
150+
| `set` returns | Path param | Query param |
151+
| -------------------- | ------------------------- | ------------------------------------------------------------------------- |
152+
| `null` / `undefined` | param is omitted | param is omitted (`undefined`) or rendered without a value (`null`, `?k`) |
153+
| `string` | single segment (`/value`) | single entry (`?k=value`) |
154+
| `string[]` | repeatable (`/a/b/c`) | repeated entries (`?k=a&k=b`) |
155155

156156
:::
157157

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,27 @@ declare module 'vue-router' {
5555
}
5656
```
5757

58+
## Query values are arrays
59+
60+
In the experimental router, every key in `route.query` has an array value. A key without a value gives `[null]`, and a missing key is `undefined`:
61+
62+
```ts
63+
// /search?q=vue&tag=a&tag=b&debug
64+
route.query.q // ['vue']
65+
route.query.tag // ['a', 'b']
66+
route.query.debug // [null]
67+
route.query.page // undefined
68+
```
69+
70+
When you navigate, also pass arrays. A non array value still works, but it shows a deprecation warning in development:
71+
72+
```ts
73+
router.push({ query: { q: ['vue'] } }) // [!code ++]
74+
router.push({ query: { q: 'vue' } }) // [!code --]
75+
```
76+
77+
An empty array is the same as a missing key: `{ tag: [] }` and `{}` give the same location.
78+
5879
## With Data Loaders
5980

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

‎packages/docs/experimental/scroll-restoration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Note that the `key` determines which pages share a saved position. By default, g
5757
import { useScrollRestoration } from 'vue-router/experimental'
5858

5959
useScrollRestoration({
60-
key: to => to.path + `?p=${to.query.p}` + to.hash,
60+
key: to => to.path + `?p=${to.query.p?.[0]}` + to.hash,
6161
})
6262
```
6363

‎packages/router/src/diagnostics.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export const diagnostics = /*#__PURE__*/ defineDiagnostics({
3838
`Route "${p.name}" uses beforeEnter, which is deprecated in the experimental router.`,
3939
fix: 'Move the condition to the route meta field and check to.meta in router.beforeEach().',
4040
},
41+
// --- experimental/route-resolver/resolver-fixed.ts ---
42+
VUE_ROUTER_D0002: {
43+
why: (p: { key: string; value: string }) =>
44+
`The query value of "${p.key}" is not an array (${p.value}). Non array query values are deprecated in the experimental router.`,
45+
fix: (p: { key: string; value: string }) =>
46+
`Wrap the value in an array: \`{ ${JSON.stringify(p.key)}: [${p.value}] }\`. Use undefined to remove the key.`,
47+
},
4148

4249
// --- router.ts ---
4350
VUE_ROUTER_R0001: {

‎packages/router/src/experimental/data-loaders/defineColadaLoader.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import type {
4343
} from '../../typed-routes'
4444
import { useRoute, useRouter } from '../../useApi'
4545
import type { Router } from '../../router'
46-
import type { LocationQuery } from '../query'
46+
import type { LocationQuery } from '../../query'
4747
import { diagnostics } from '../../diagnostics'
4848

4949
/**

‎packages/router/src/experimental/data-loaders/utils.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { LocationQuery } from '../query'
1+
import type { LocationQuery } from '../../query'
22
import type { Router } from '../../router'
33
import type { RouteLocationNormalizedLoaded } from '../../typed-routes'
44
import type { DataLoaderEntryBase, UseDataLoader } from './createDataLoader'

‎packages/router/src/experimental/location.spec.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('parseURL', () => {
2828
fullPath: '/foo?f=foo#hash',
2929
path: '/foo',
3030
hash: '#hash',
31-
query: { f: 'foo' },
31+
query: { f: ['foo'] },
3232
})
3333
})
3434

@@ -37,7 +37,7 @@ describe('parseURL', () => {
3737
fullPath: '/?f=foo',
3838
path: '/',
3939
hash: '',
40-
query: { f: 'foo' },
40+
query: { f: ['foo'] },
4141
})
4242
})
4343

@@ -64,7 +64,7 @@ describe('parseURL', () => {
6464
fullPath: '/parent/foo?f=foo#hash',
6565
path: '/parent/foo',
6666
hash: '#hash',
67-
query: { f: 'foo' },
67+
query: { f: ['foo'] },
6868
})
6969
})
7070

@@ -73,7 +73,7 @@ describe('parseURL', () => {
7373
fullPath: '/parent/bar?f=foo',
7474
path: '/parent/bar',
7575
hash: '',
76-
query: { f: 'foo' },
76+
query: { f: ['foo'] },
7777
})
7878
})
7979

@@ -92,8 +92,8 @@ describe('parseURL', () => {
9292
path: '/foo',
9393
hash: '',
9494
query: {
95-
a: 'one',
96-
b: 'two',
95+
a: ['one'],
96+
b: ['two'],
9797
},
9898
})
9999
})
@@ -112,7 +112,7 @@ describe('parseURL', () => {
112112
fullPath: '/foo?a=one#bar',
113113
path: '/foo',
114114
hash: '#bar',
115-
query: { a: 'one' },
115+
query: { a: ['one'] },
116116
})
117117
})
118118

@@ -136,7 +136,7 @@ describe('parseURL', () => {
136136
fullPath: '/foo/?a=two#?a=one',
137137
path: '/foo/',
138138
hash: '#?a=one',
139-
query: { a: 'two' },
139+
query: { a: ['two'] },
140140
})
141141
})
142142

@@ -231,7 +231,7 @@ describe('parseURL', () => {
231231
fullPath: '/parent/bar?o=o',
232232
path: '/parent/bar',
233233
hash: '',
234-
query: { o: 'o' },
234+
query: { o: ['o'] },
235235
})
236236
})
237237

‎packages/router/src/experimental/query.spec.ts‎

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ describe('parseQuery', () => {
1010

1111
it('works with leading ?', () => {
1212
expect(parseQuery('?foo=a')).toEqual({
13-
foo: 'a',
13+
foo: ['a'],
1414
})
1515
expect(parseQuery('foo=a')).toEqual({
16-
foo: 'a',
16+
foo: ['a'],
1717
})
1818
expect(parseQuery('?')).toEqual({})
1919
})
@@ -27,28 +27,28 @@ describe('parseQuery', () => {
2727

2828
it('decodes values in query', () => {
2929
expect(parseQuery('e=%25')).toEqual({
30-
e: '%',
30+
e: ['%'],
3131
})
3232
})
3333

3434
it('parses empty string values', () => {
3535
expect(parseQuery('e=&c=a')).toEqual({
36-
e: '',
37-
c: 'a',
36+
e: [''],
37+
c: ['a'],
3838
})
3939
})
4040

4141
it('allows = inside values', () => {
4242
expect(parseQuery('e=c=a')).toEqual({
43-
e: 'c=a',
43+
e: ['c=a'],
4444
})
4545
})
4646

4747
it('parses empty values as null', () => {
4848
expect(parseQuery('e&b&c=a')).toEqual({
49-
e: null,
50-
b: null,
51-
c: 'a',
49+
e: [null],
50+
b: [null],
51+
c: ['a'],
5252
})
5353
})
5454

@@ -69,13 +69,13 @@ describe('parseQuery', () => {
6969

7070
it('decodes the + as space', () => {
7171
expect(parseQuery('a+b=c+d')).toEqual({
72-
'a b': 'c d',
72+
'a b': ['c d'],
7373
})
7474
})
7575

7676
it('decodes the encoded + as +', () => {
7777
expect(parseQuery('a%2Bb=c%2Bd')).toEqual({
78-
'a+b': 'c+d',
78+
'a+b': ['c+d'],
7979
})
8080
})
8181

@@ -96,33 +96,33 @@ describe('parseQuery', () => {
9696
it('does not pollute the prototype with __proto__', () => {
9797
const result = parseQuery('__proto__=polluted') as Record<string, unknown>
9898
expect(Object.getPrototypeOf(result)).toBe(null)
99-
expect(result['__proto__']).toBe('polluted')
99+
expect(result['__proto__']).toEqual(['polluted'])
100100
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
101101
})
102102

103103
it('treats constructor as a regular key', () => {
104104
const result = parseQuery('constructor=foo')
105105
expect(Object.getPrototypeOf(result)).toBe(null)
106-
expect(result).toEqual({ constructor: 'foo' })
106+
expect(result).toEqual({ constructor: ['foo'] })
107107
expect(Object.prototype.constructor).toBe(Object)
108108
})
109109
})
110110

111111
describe('normalizeQuery', () => {
112112
it('removes keys with undefined values', () => {
113-
expect(normalizeQuery({ a: undefined, b: 'x' })).toEqual({ b: 'x' })
113+
expect(normalizeQuery({ a: undefined, b: 'x' })).toEqual({ b: ['x'] })
114114
})
115115

116-
it('preserves null values', () => {
117-
expect(normalizeQuery({ a: null })).toEqual({ a: null })
116+
it('wraps null values in an array', () => {
117+
expect(normalizeQuery({ a: null })).toEqual({ a: [null] })
118118
})
119119

120120
it('coerces numbers to strings', () => {
121-
expect(normalizeQuery({ a: 1 })).toEqual({ a: '1' })
121+
expect(normalizeQuery({ a: 1 })).toEqual({ a: ['1'] })
122122
})
123123

124-
it('preserves string values', () => {
125-
expect(normalizeQuery({ a: 'x' })).toEqual({ a: 'x' })
124+
it('wraps string values in an array', () => {
125+
expect(normalizeQuery({ a: 'x' })).toEqual({ a: ['x'] })
126126
})
127127

128128
it('coerces values inside arrays', () => {
@@ -137,6 +137,10 @@ describe('normalizeQuery', () => {
137137
})
138138
})
139139

140+
it('keeps existing arrays', () => {
141+
expect(normalizeQuery({ a: ['x'], b: [] })).toEqual({ a: ['x'], b: [] })
142+
})
143+
140144
it('handles empty / undefined input', () => {
141145
expect(normalizeQuery(undefined)).toEqual({})
142146
expect(normalizeQuery({})).toEqual({})

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

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { decode, PLUS_RE } from './encoding'
22
import { isArray } from '../utils'
3-
4-
/**
5-
* NOTE: some types here are duplicated from ../query.ts
6-
* because they will change to always have (string | null)[] values
7-
*/
3+
import { diagnostics } from '../diagnostics'
84

95
/**
106
* Possible values in normalized {@link LocationQuery}. `null` renders the query
@@ -22,14 +18,12 @@ export type LocationQueryValue = string | null
2218
export type LocationQueryValueRaw = LocationQueryValue | number | undefined
2319

2420
/**
25-
* Normalized query object that appears in {@link RouteLocationNormalized}
21+
* Normalized query object that appears in {@link RouteLocationNormalized}.
22+
* Every present key has an array value. `?a` gives `{ a: [null] }`.
2623
*
2724
* @public
2825
*/
29-
export type LocationQuery = Record<
30-
string,
31-
LocationQueryValue | LocationQueryValue[]
32-
>
26+
export type LocationQuery = Record<string, LocationQueryValue[]>
3327

3428
/**
3529
* Loose {@link LocationQuery} object that can be passed to functions like
@@ -63,25 +57,21 @@ export function experimental_parseQuery(search: string): LocationQuery {
6357
const key = decode(eqPos < 0 ? searchParam : searchParam.slice(0, eqPos))
6458
const value = eqPos < 0 ? null : decode(searchParam.slice(eqPos + 1))
6559

66-
if (key in query) {
67-
let currentValue = query[key]
68-
if (!isArray(currentValue)) {
69-
currentValue = query[key] = [currentValue]
70-
}
71-
;(currentValue as LocationQueryValue[]).push(value)
72-
} else {
73-
query[key] = value
74-
}
60+
query[key] ??= []
61+
query[key].push(value)
7562
}
7663
return query
7764
}
7865

7966
/**
8067
* Transforms a {@link LocationQueryRaw} into a {@link LocationQuery} by casting
81-
* numbers into strings, removing keys with an undefined value and replacing
82-
* undefined with null in arrays. Uses `Object.create(null)` so the returned
68+
* numbers into strings, wrapping non array values in an array, removing keys
69+
* with an undefined value and replacing undefined with null in arrays. Uses `Object.create(null)` so the returned
8370
* object cannot be exploited via prototype pollution.
8471
*
72+
* FIXME: remove once only arrays are accepted in {@link LocationQueryRaw}.
73+
* Normalization will then only cast numbers and remove undefined keys.
74+
*
8575
* @param query - query object to normalize
8676
* @returns a normalized query object
8777
*/
@@ -93,13 +83,27 @@ export function experimental_normalizeQuery(
9383
for (const key in query) {
9484
const value = query[key]
9585
if (value !== undefined) {
96-
normalizedQuery[key] = isArray(value)
97-
? value.map(v => (v == null ? null : '' + v))
98-
: value == null
99-
? value
100-
: '' + value
86+
normalizedQuery[key] = (isArray(value) ? value : [value]).map(v =>
87+
v == null ? null : '' + v
88+
)
10189
}
10290
}
10391

10492
return normalizedQuery
10593
}
94+
95+
/**
96+
* Warns about raw query values that are not arrays. Dev only.
97+
* FIXME: remove with the non array normalization in
98+
* {@link experimental_normalizeQuery}.
99+
*
100+
* @param query - raw query passed by the user
101+
*/
102+
export function warnNonArrayQueryValues(query: LocationQueryRaw | undefined) {
103+
for (const key in query) {
104+
const value = query[key]
105+
if (value !== undefined && !isArray(value)) {
106+
diagnostics.VUE_ROUTER_D0002({ key, value: JSON.stringify(value) })
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)