Skip to content

Commit cef5cfa

Browse files
committed
feat(experimental): configure required and default hash params
1 parent 31019cc commit cef5cfa

14 files changed

Lines changed: 627 additions & 103 deletions

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,29 @@ definePage({
232232
</script>
233233
```
234234

235-
Hash params are optional. A parser error or a nullish result becomes `null` and does not reject the route. Without a custom getter, an absent hash becomes `null`.
235+
Hash params are optional by default. A parser error or a nullish result becomes `null`. Set `required: true` to reject the match instead, or provide a default:
236+
237+
```vue
238+
<script setup lang="ts">
239+
definePage({
240+
params: {
241+
hash: {
242+
section: {
243+
parser: 'section',
244+
required: true,
245+
default: () => ({ heading: 'overview', tab: 'vue' }),
246+
},
247+
},
248+
},
249+
})
250+
</script>
251+
```
252+
253+
- `parser`: parser name. Omit to keep the hash string.
254+
- `required`: reject the match when parsing fails and no default exists. Defaults to `false`.
255+
- `default`: a parsed value or factory used when the hash is absent or parsing fails, including for required params. A declared default takes precedence over the getter for an absent hash.
256+
257+
Without a custom getter or a default, an absent hash becomes `null`.
236258

237259
The parser receives the entire hash string: an empty string when absent, or a string that starts with `#`. To extract several values, return an object from this one parser. Only the deepest matched route's hash parser runs.
238260

@@ -262,4 +284,4 @@ router.push({
262284
})
263285
```
264286

265-
You can omit the hash param during navigation or pass `null` to clear it. Nullish values produce an empty hash without calling the setter. The setter returns the entire hash, including `#`, or an empty string. The router then runs the getter to validate and normalize the result.
287+
You can omit an optional hash param or one with a default during navigation. Pass `null` to clear an optional hash param. Required params without a default must be provided. Nullish values produce an empty hash without calling the setter. The setter returns the entire hash, including `#`, or an empty string. The router then runs the getter to validate and normalize the result.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export {
7878
type ParamParsers as _ParamParsers,
7979
type ParamParsers_Native as _ParamParsers_Native,
8080
type ParamParserTypeOf,
81+
type DefinePageHashParamOptions,
82+
type DefinePageHashParamOptionsAny as _DefinePageHashParamOptionsAny,
8183
type DefinePageQueryParamOptions,
8284
type DefinePageQueryParamOptionsAny as _DefinePageQueryParamOptionsAny,
8385
type PathParamNamesForFilePath as _PathParamNamesForFilePath,

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,91 @@ describe('hash param extraction', () => {
7474
expect(hash.match('#intro')).toEqual({ fragment: null })
7575
})
7676

77+
it('rejects required hashes without a default', () => {
78+
const hash = new MatcherPatternHashParam(
79+
'fragment',
80+
{
81+
get: value => (value === '#allowed' ? value : miss()),
82+
},
83+
undefined,
84+
true
85+
)
86+
const resolver = createFixedResolver([
87+
{ name: 'required', path: new MatcherPatternPathStatic('/page'), hash },
88+
{ name: 'fallback', path: new MatcherPatternPathStatic('/page') },
89+
])
90+
expect(resolver.resolve('/page#allowed').name).toBe('required')
91+
expect(resolver.resolve('/page#other').name).toBe('fallback')
92+
expect(resolver.resolve('/page').name).toBe('fallback')
93+
expect(() => resolver.resolve({ name: 'required', params: {} })).toThrow()
94+
const nullHash = new MatcherPatternHashParam(
95+
'fragment',
96+
{
97+
get: () => null,
98+
},
99+
undefined,
100+
true
101+
)
102+
expect(() => nullHash.match('#other')).toThrow()
103+
})
104+
105+
it.each([false, true])(
106+
'uses a default for missing or rejected hashes (required: %s)',
107+
required => {
108+
const hash = new MatcherPatternHashParam(
109+
'fragment',
110+
{
111+
get: value => (value === '#allowed' ? value : miss()),
112+
},
113+
'#default',
114+
required
115+
)
116+
const resolver = createFixedResolver([
117+
{ name: 'page', path: new MatcherPatternPathStatic('/page'), hash },
118+
])
119+
expect(resolver.resolve('/page').params).toEqual({ fragment: '#default' })
120+
expect(resolver.resolve('/page#other').params).toEqual({
121+
fragment: '#default',
122+
})
123+
expect(resolver.resolve('/page#allowed').params).toEqual({
124+
fragment: '#allowed',
125+
})
126+
expect(resolver.resolve({ name: 'page', params: {} })).toMatchObject({
127+
fullPath: '/page',
128+
params: { fragment: '#default' },
129+
})
130+
}
131+
)
132+
133+
it('uses the declared default when the hash is absent even if the getter accepts empty strings', () => {
134+
const hash = new MatcherPatternHashParam(
135+
'fragment',
136+
{
137+
get: value => value || '#parser-default',
138+
},
139+
'#declared-default'
140+
)
141+
expect(hash.match('')).toEqual({ fragment: '#declared-default' })
142+
expect(hash.match('#valid')).toEqual({ fragment: '#valid' })
143+
})
144+
145+
it('evaluates factory defaults on each miss and preserves successful falsy results', () => {
146+
const hash = new MatcherPatternHashParam(
147+
'fragment',
148+
{
149+
get: value => (value === '#valid' ? [] : undefined),
150+
},
151+
() => ['default'],
152+
true
153+
)
154+
const first = hash.match('')
155+
const second = hash.match('#invalid')
156+
expect(first).toEqual({ fragment: ['default'] })
157+
expect(second).toEqual(first)
158+
expect(second.fragment).not.toBe(first.fragment)
159+
expect(hash.match('#valid')).toEqual({ fragment: [] })
160+
})
161+
77162
it('keeps strings unchanged without a custom parser', () => {
78163
const hash = new MatcherPatternHashParam('fragment')
79164
expect(hash.match('#intro')).toEqual({ fragment: '#intro' })

‎packages/router/src/experimental/route-resolver/matchers/matcher-pattern-hash.test-d.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { expectTypeOf } from 'vitest'
2+
import { definePage } from '../../runtime'
3+
import type { DefinePageHashParamOptions } from '../../runtime'
24
import { MatcherPatternHashParam } from './matcher-pattern-hash'
35

46
const hash = new MatcherPatternHashParam('section', {
@@ -18,3 +20,31 @@ hash.build({ section: undefined })
1820

1921
const stringHash = new MatcherPatternHashParam('fragment')
2022
expectTypeOf(stringHash.match('')).toEqualTypeOf<{ fragment: string | null }>()
23+
24+
expectTypeOf<DefinePageHashParamOptions<'int'>['default']>().toEqualTypeOf<
25+
number | (() => number) | undefined
26+
>()
27+
definePage({
28+
params: {
29+
hash: { count: { parser: 'int', required: true, default: () => 1 } },
30+
},
31+
})
32+
definePage({
33+
params: { hash: { section: { default: '#overview' } } },
34+
})
35+
definePage({
36+
params: {
37+
hash: {
38+
// @ts-expect-error: an int parser requires a numeric default
39+
count: { parser: 'int', default: 'one' },
40+
},
41+
},
42+
})
43+
definePage({
44+
params: {
45+
hash: {
46+
// @ts-expect-error: hash parsers receive the entire string without a query format
47+
section: { parser: 'string', format: 'array' },
48+
},
49+
},
50+
})

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { toValue } from 'vue'
2+
import { miss } from './errors'
13
import type { MatcherPatternHash } from './matcher-pattern'
24
import type { ParamParser } from './param-parsers'
35

@@ -10,15 +12,30 @@ export class MatcherPatternHashParam<
1012
> implements MatcherPatternHash<Record<ParamName, T | null>> {
1113
constructor(
1214
private paramName: ParamName,
13-
private parser: ParamParser<T, string> = {}
15+
private parser: ParamParser<T, string> = {},
16+
private defaultValue?: (() => T) | T,
17+
private required?: boolean
1418
) {}
1519

1620
match(hash: string): Record<ParamName, T | null> {
21+
if (hash === '' && this.defaultValue !== undefined) {
22+
return { [this.paramName]: toValue(this.defaultValue) } as Record<
23+
ParamName,
24+
T | null
25+
>
26+
}
1727
let value: T | string | null = null
1828
try {
1929
value = this.parser.get ? (this.parser.get(hash) ?? null) : hash || null
20-
} catch {
21-
// Hash params are optional; parser errors fall back to null.
30+
} catch (error) {
31+
if (this.required && this.defaultValue === undefined) throw error
32+
}
33+
if (value == null) {
34+
if (this.defaultValue !== undefined) {
35+
value = toValue(this.defaultValue)
36+
} else if (this.required) {
37+
miss()
38+
}
2239
}
2340
return { [this.paramName]: value } as Record<ParamName, T | null>
2441
}

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,9 @@ export interface DefinePage<FilePath extends string = string> extends Partial<
104104
query?: Record<string, DefinePageQueryParamOptionsAny | ParamParserType>
105105

106106
/**
107-
* One optional parameter extracted from the whole hash, including `#`, or an empty string.
108-
* Parser errors fall back to `null`.
107+
* One parameter extracted from the whole hash, including `#`, or an empty string.
109108
*/
110-
hash?: Record<string, ParamParserType>
109+
hash?: Record<string, ParamParserType | DefinePageHashParamOptionsAny>
111110
}
112111
}
113112

@@ -228,6 +227,46 @@ export interface DefinePageQueryParamOptions<
228227
required?: boolean
229228
}
230229

230+
/**
231+
* Configures one parameter extracted from the whole hash.
232+
*/
233+
export interface DefinePageHashParamOptions<
234+
Parser extends ParamParserType = ParamParserType,
235+
> {
236+
/**
237+
* Parser to apply to the whole hash, including `#`. Without an explicit
238+
* default, the parser receives `''` when the hash is absent.
239+
* If omitted, keeps the hash string.
240+
*/
241+
parser?: Parser
242+
243+
/**
244+
* Value or factory used when the hash is absent, parsing fails, or the
245+
* parser returns `null` or `undefined`. For an absent hash (`''`), this
246+
* default is used before the parser runs, so it takes precedence over
247+
* the parser's own fallback for an empty hash.
248+
*/
249+
default?: ParamParserTypeOf<Parser> | (() => ParamParserTypeOf<Parser>)
250+
251+
/**
252+
* Whether a parser failure or missing result prevents the route from
253+
* matching when no default is provided. Optional hash params return
254+
* `null` in these cases.
255+
*
256+
* @default false
257+
*/
258+
required?: boolean
259+
}
260+
261+
/**
262+
* Distributive hash options with defaults typed by parser name.
263+
*
264+
* @internal
265+
*/
266+
export type DefinePageHashParamOptionsAny<
267+
P extends ParamParserType = ParamParserType,
268+
> = P extends ParamParserType ? DefinePageHashParamOptions<P> : never
269+
231270
/**
232271
* TODO: native parsers ideas:
233272
* - json -> just JSON.parse(value)

0 commit comments

Comments
 (0)