Skip to content

Commit e9e7fe4

Browse files
authored
feat(runtime-vapor): component props validator (#114)
1 parent 9578288 commit e9e7fe4

File tree

2 files changed

+198
-12
lines changed

2 files changed

+198
-12
lines changed

packages/runtime-vapor/__tests__/componentProps.spec.ts

+123-10
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ describe('component props (vapor)', () => {
232232
expect(props.bar).toEqual({ a: 1 })
233233
expect(props.baz).toEqual(defaultBaz)
234234
// expect(defaultFn).toHaveBeenCalledTimes(1) // failed: (caching is not supported)
235-
expect(defaultFn).toHaveBeenCalledTimes(2)
235+
expect(defaultFn).toHaveBeenCalledTimes(3)
236236
expect(defaultBaz).toHaveBeenCalledTimes(0)
237237

238238
// #999: updates should not cause default factory of unchanged prop to be
@@ -358,25 +358,138 @@ describe('component props (vapor)', () => {
358358
reset()
359359
})
360360

361-
test.todo('validator', () => {
362-
// TODO: impl validator
361+
describe('validator', () => {
362+
test('validator should be called with two arguments', () => {
363+
let args: any
364+
const mockFn = vi.fn((..._args: any[]) => {
365+
args = _args
366+
return true
367+
})
368+
369+
const Comp = defineComponent({
370+
props: {
371+
foo: {
372+
type: Number,
373+
validator: (value: any, props: any) => mockFn(value, props),
374+
},
375+
bar: {
376+
type: Number,
377+
},
378+
},
379+
render() {
380+
const t0 = template('<div/>')
381+
const n0 = t0()
382+
return n0
383+
},
384+
})
385+
386+
const props = {
387+
get foo() {
388+
return 1
389+
},
390+
get bar() {
391+
return 2
392+
},
393+
}
394+
395+
render(Comp, props, host)
396+
expect(mockFn).toHaveBeenCalled()
397+
// NOTE: Vapor Component props defined by getter. So, `props` not Equal to `{ foo: 1, bar: 2 }`
398+
// expect(mockFn).toHaveBeenCalledWith(1, { foo: 1, bar: 2 })
399+
expect(args.length).toBe(2)
400+
expect(args[0]).toBe(1)
401+
expect(args[1].foo).toEqual(1)
402+
expect(args[1].bar).toEqual(2)
403+
})
404+
405+
// TODO: impl setter and warnner
406+
test.todo(
407+
'validator should not be able to mutate other props',
408+
async () => {
409+
const mockFn = vi.fn((...args: any[]) => true)
410+
const Comp = defineComponent({
411+
props: {
412+
foo: {
413+
type: Number,
414+
validator: (value: any, props: any) => !!(props.bar = 1),
415+
},
416+
bar: {
417+
type: Number,
418+
validator: (value: any) => mockFn(value),
419+
},
420+
},
421+
render() {
422+
const t0 = template('<div/>')
423+
const n0 = t0()
424+
return n0
425+
},
426+
})
427+
428+
render(
429+
Comp,
430+
{
431+
get foo() {
432+
return 1
433+
},
434+
get bar() {
435+
return 2
436+
},
437+
},
438+
host,
439+
)
440+
expect(
441+
`Set operation on key "bar" failed: target is readonly.`,
442+
).toHaveBeenWarnedLast()
443+
expect(mockFn).toHaveBeenCalledWith(2)
444+
},
445+
)
363446
})
364447

365448
test.todo('warn props mutation', () => {
366449
// TODO: impl warn
367450
})
368451

369-
test.todo('warn absent required props', () => {
370-
// TODO: impl warn
452+
test('warn absent required props', () => {
453+
const Comp = defineComponent({
454+
props: {
455+
bool: { type: Boolean, required: true },
456+
str: { type: String, required: true },
457+
num: { type: Number, required: true },
458+
},
459+
setup() {
460+
return () => null
461+
},
462+
})
463+
render(Comp, {}, host)
464+
expect(`Missing required prop: "bool"`).toHaveBeenWarned()
465+
expect(`Missing required prop: "str"`).toHaveBeenWarned()
466+
expect(`Missing required prop: "num"`).toHaveBeenWarned()
371467
})
372468

373-
test.todo('warn on type mismatch', () => {
374-
// TODO: impl warn
375-
})
469+
// NOTE: type check is not supported in vapor
470+
// test('warn on type mismatch', () => {})
376471

377472
// #3495
378-
test.todo('should not warn required props using kebab-case', async () => {
379-
// TODO: impl warn
473+
test('should not warn required props using kebab-case', async () => {
474+
const Comp = defineComponent({
475+
props: {
476+
fooBar: { type: String, required: true },
477+
},
478+
setup() {
479+
return () => null
480+
},
481+
})
482+
483+
render(
484+
Comp,
485+
{
486+
get ['foo-bar']() {
487+
return 'hello'
488+
},
489+
},
490+
host,
491+
)
492+
expect(`Missing required prop: "fooBar"`).not.toHaveBeenWarned()
380493
})
381494

382495
test('props type support BigInt', () => {

packages/runtime-vapor/src/componentProps.ts

+75-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
isFunction,
1313
isReservedProp,
1414
} from '@vue/shared'
15-
import { shallowReactive, toRaw } from '@vue/reactivity'
15+
import { shallowReactive, shallowReadonly, toRaw } from '@vue/reactivity'
16+
import { warn } from './warning'
1617
import {
1718
type Component,
1819
type ComponentInternalInstance,
@@ -35,7 +36,7 @@ export interface PropOptions<T = any, D = T> {
3536
type?: PropType<T> | true | null
3637
required?: boolean
3738
default?: D | DefaultFactory<D> | null | undefined | object
38-
validator?(value: unknown): boolean
39+
validator?(value: unknown, props: Data): boolean
3940
/**
4041
* @internal
4142
*/
@@ -142,6 +143,11 @@ export function initProps(
142143
}
143144
}
144145

146+
// validation
147+
if (__DEV__) {
148+
validateProps(rawProps || {}, props, instance)
149+
}
150+
145151
instance.props = shallowReactive(props)
146152
}
147153

@@ -263,3 +269,70 @@ function getTypeIndex(
263269
}
264270
return -1
265271
}
272+
273+
/**
274+
* dev only
275+
*/
276+
function validateProps(
277+
rawProps: Data,
278+
props: Data,
279+
instance: ComponentInternalInstance,
280+
) {
281+
const resolvedValues = toRaw(props)
282+
const options = instance.propsOptions[0]
283+
for (const key in options) {
284+
let opt = options[key]
285+
if (opt == null) continue
286+
validateProp(
287+
key,
288+
resolvedValues[key],
289+
opt,
290+
__DEV__ ? shallowReadonly(resolvedValues) : resolvedValues,
291+
!hasOwn(rawProps, key) && !hasOwn(rawProps, hyphenate(key)),
292+
)
293+
}
294+
}
295+
296+
/**
297+
* dev only
298+
*/
299+
function validateProp(
300+
name: string,
301+
value: unknown,
302+
prop: PropOptions,
303+
props: Data,
304+
isAbsent: boolean,
305+
) {
306+
const { required, validator } = prop
307+
// required!
308+
if (required && isAbsent) {
309+
warn('Missing required prop: "' + name + '"')
310+
return
311+
}
312+
// missing but optional
313+
if (value == null && !required) {
314+
return
315+
}
316+
// NOTE: type check is not supported in vapor
317+
// // type check
318+
// if (type != null && type !== true) {
319+
// let isValid = false
320+
// const types = isArray(type) ? type : [type]
321+
// const expectedTypes = []
322+
// // value is valid as long as one of the specified types match
323+
// for (let i = 0; i < types.length && !isValid; i++) {
324+
// const { valid, expectedType } = assertType(value, types[i])
325+
// expectedTypes.push(expectedType || '')
326+
// isValid = valid
327+
// }
328+
// if (!isValid) {
329+
// warn(getInvalidTypeMessage(name, value, expectedTypes))
330+
// return
331+
// }
332+
// }
333+
334+
// custom validator
335+
if (validator && !validator(value, props)) {
336+
warn('Invalid prop: custom validator check failed for prop "' + name + '".')
337+
}
338+
}

0 commit comments

Comments
 (0)