Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: $rewardEarly implementation #1240

Open
wants to merge 3 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
59 changes: 50 additions & 9 deletions packages/test-project/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,56 @@
<div id="app">
<div class="navigation">
<ul>
<li><router-link to="/">Simple Form</router-link></li>
<li><router-link to="/async-simple">Async Form</router-link></li>
<li><router-link to="/i18n-simple">i18n Simple Form</router-link></li>
<li><router-link to="/old-api">Old api</router-link></li>
<li><router-link to="/nested-validations">Nested Validations</router-link></li>
<li><router-link to="/nested-ref">Nested Ref</router-link></li>
<li><router-link to="/collection-validations">Collection Validations</router-link></li>
<li><router-link to="/external-validations">External Validations</router-link></li>
<li><router-link to="/nested-validations-with-scopes">Nested Validations with Scopes</router-link></li>
<li>
<router-link to="/">
Simple Form
</router-link>
</li>
<li>
<router-link to="/async-simple">
Async Form
</router-link>
</li>
<li>
<router-link to="/i18n-simple">
i18n Simple Form
</router-link>
</li>
<li>
<router-link to="/old-api">
Old api
</router-link>
</li>
<li>
<router-link to="/nested-validations">
Nested Validations
</router-link>
</li>
<li>
<router-link to="/nested-ref">
Nested Ref
</router-link>
</li>
<li>
<router-link to="/collection-validations">
Collection Validations
</router-link>
</li>
<li>
<router-link to="/external-validations">
External Validations
</router-link>
</li>
<li>
<router-link to="/nested-validations-with-scopes">
Nested Validations with Scopes
</router-link>
</li>
<li>
<router-link to="/reward-early">
$rewardEarly
</router-link>
</li>
</ul>
</div>
<router-view />
Expand Down
144 changes: 144 additions & 0 deletions packages/test-project/src/components/RewardEarly.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@

<script setup>
import { useVuelidate } from '@vuelidate/core'
import { required, email, minLength, helpers } from '@vuelidate/validators'
import { reactive, computed, version } from 'vue'

function Contact () {
return {
id: Date.now(),
name: '',
email: ''
}
}

const asyncName = helpers.withAsync({
$validator: (value) => {
return new Promise((resolve) => {
setTimeout(() => {
if (value === 'renato') {
resolve({ $valid: true })
} else {
resolve({ $valid: false })
}
}, 100)
})
},
$message: 'Name must be renato'
})

const contactValidationSchema = {
name: { required, minLength: minLength(3) },
email: { email }
}

const defaultContact = new Contact()
const state = reactive({
name: '',
address: '',
contacts: {
[defaultContact.id]: defaultContact
}
})

function addContact () {
const newContact = new Contact()
state.contacts[newContact.id] = newContact
}

function removeContact (id) {
delete state.contacts[id]
}

const rules = computed(() => ({
name: { required, m: minLength(3), asyncName },
address: { required },
contacts: Object.keys(state.contacts).reduce((acc, key) => {
acc[key] = contactValidationSchema
return acc
}, {})
}))

const v$ = useVuelidate(rules, state, { $rewardEarly: true })
</script>

<template>
<div>
<div class="field">
<label>
Name
<input
v-model="v$.name.$model"
@blur="v$.name.$commit"
>
</label>
<p
v-for="error of v$.name.$errors"
:key="error.$uid"
>
{{ error.$message }}
</p>
</div>

<div class="field">
<label>
Address
<input
v-model="v$.address.$model"
@blur="v$.address.$commit"
>
</label>
<p
v-for="error of v$.address.$errors"
:key="error.$uid"
>
{{ error.$message }}
</p>
</div>

<fieldset
v-for="(contact, id) in v$.contacts.$model"
:key="id"
>
<legend>Contact {{ id }}</legend>
<div>
<label>
name
<input
v-model="v$.contacts[id].name.$model"
@blur="v$.contacts[id].name.$commit"
>
</label>
<p
v-for="error of v$.contacts[id].name.$errors"
:key="error.$uid"
>
{{ error.$message }}
</p>
</div>
<div>
<label>
email
<input
v-model="v$.contacts[id].email.$model"
@blur="v$.contacts[id].email.$commit"
>
</label>
<p
v-for="error of v$.contacts[id].email.$errors"
:key="error.$uid"
>
{{ error.$message }}
</p>
</div>
<button @click="removeContact(id)">
X
</button>
</fieldset>
<button @click="addContact">
Add contact
</button>

<pre><code>{{ { vue: version, $silentErrors: v$.$silentErrors, $silentInvalids: v$.$silentInvalids } }}</code></pre>
</div>
</template>
5 changes: 5 additions & 0 deletions packages/test-project/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import I18nSimpleForm from './components/I18nSimpleForm.vue'
import ExternalValidationsForm from './components/ExternalValidationsForm.vue'
import AsyncValidators from './components/AsyncValidators.vue'
import NestedValidationsWithScopes from './components/NestedValidationsWithScopes/ParentValidator.vue'
import RewardEarly from './components/RewardEarly.vue'

export const routes = [
{
Expand Down Expand Up @@ -44,5 +45,9 @@ export const routes = [
{
path: '/nested-validations-with-scopes',
component: NestedValidationsWithScopes
},
{
path: '/reward-early',
component: RewardEarly
}
]
35 changes: 33 additions & 2 deletions packages/vuelidate/src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function createValidationResults (rules, model, key, resultsCache, path, config,
if (!cachedResult.$partial) return cachedResult
// remove old watchers
cachedResult.$unwatch()
cachedResult.__unwatchInvalidResult?.()
// use the `$dirty.value`, so we dont save references by accident
$dirty.value = cachedResult.$dirty.value
}
Expand Down Expand Up @@ -139,11 +140,41 @@ function createValidationResults (rules, model, key, resultsCache, path, config,

result.$invalid = computed(() => {
const r = ruleKeys.some(ruleKey => unwrap(result[ruleKey].$invalid))
// cache the last invalid state
$lastInvalidState.value = r
return !!result.$externalResults.value.length || r
})

// https://github.com/vuelidate/vuelidate/issues/1237
// 1. Moved $lastInvalid assignment from the results.$invalid computed
// because computed props should be side-effect free.
// 2. Added $lastCommittedOn as a watch source because 3.4.x vue will cache
// computed more aggressively and $lastInvalidState would be out of sync
// in the "commit() when valid" scenario:
// - commit() sets $lastInvalidState to true
// - validators run and return invalid as false
// - result.$invalid is already false, so it will not recompute (cached)
// - the watcher will not be triggered because $result.$invalid dep did
// not change.
// - last $invalidState is not sync to false (to match result.$invalid), and
// thus $model change triggers validators to run immediately.
// I don't know if it's out-of-scope of this PR to update vue, but 3.4.x
// brought many changes to computed optimization which vuelidate relies on
// heavily and it's the future.
// 3. flush "post" is required for async validators to work in this mode, otherwise
// last invalid state will be flipped to false since sync validators run first
// and turn invalid to false "before" pending is set to true and it will prevent
// them from running (Guessing). At least is required for 3.4.x and that's
// the version the majority should be using.
if (config.$rewardEarly) {
result.__unwatchInvalidResult = watch(
[result.$invalid, $lastCommittedOn],
([invalidState]) => { $lastInvalidState.value = invalidState },
{
deep: true,
flush: 'post' // [3]
}
)
}

result.$pending = computed(() =>
ruleKeys.some(ruleKey => unwrap(result[ruleKey].$pending))
)
Expand Down
101 changes: 101 additions & 0 deletions packages/vuelidate/test/unit/specs/validation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,107 @@ describe('useVuelidate', () => {
validatorName: 'isEven'
})
})

describe('with DOM context', () => {
// https://github.com/vuelidate/vuelidate/issues/1237
// I could not assert this using pure javascript manipulation on the validation
// state object. The issue only occurs because the side-effect that was present
// inside the result.$invalid computed property gets out-of-sync with the actual
// $model value as vue does not update DOM bindings synchronously. I understand
// that in theory vuelidate is not dependent on whatever we wire in the DOM as it's
// pure model based, but some edge cases like this could appear.
it('it works correctly when $model is patched via DOM updates', async () => {
const fooIsRequired = {
$validator: jest.fn((v) => !!v),
$message: 'Foo is required'
}
const barIsRequired = {
$validator: jest.fn((v) => !!v),
$message: 'Bar is required'
}

const component = {
setup () {
const state = reactive({
foo: '',
bar: ''
})

const rules = {
foo: { fooIsRequired },
bar: { barIsRequired }
}

const v$ = useVuelidate(rules, state, { $rewardEarly: true })
return { v$ }
},
template: `
<div>
<div class="field">
<input
name="foo"
v-model="v$.foo.$model"
@blur="v$.foo.$commit"
>
<p
data-test-id="foo-errors"
v-for="error of v$.foo.$errors"
:key="error.$uid"
>
{{ error.$message }}
</p>
</div>
<div class="field">
<input
name="bar"
v-model="v$.bar.$model"
@blur="v$.bar.$commit"
>
<p
data-test-id="bar-errors"
v-for="error of v$.bar.$errors"
:key="error.$uid"
>
{{ error.$message }}
</p>
</div>
</div>
`
}
const wrapper = mount(component)
const fooInput = wrapper.find('input[name="foo"]')
const barInput = wrapper.find('input[name="bar"]')
const findFooInputErrors = () => wrapper.find('[data-test-id="foo-errors"]')
const findBarInputErrors = () => wrapper.find('[data-test-id="bar-errors"]')

await fooInput.setValue('foo')
await fooInput.trigger('blur')
expect(findFooInputErrors().exists()).toBe(false)

await fooInput.setValue('')
await fooInput.trigger('blur')
expect(findFooInputErrors().exists()).toBe(true)
expect(findFooInputErrors().text()).toContain(fooIsRequired.$message)

await barInput.setValue('bar')
await barInput.trigger('blur')
expect(findBarInputErrors().exists()).toBe(false)

// THE ISSUE. the errors should not appear until we blur the input because
// the in the previous commit the input was valid. So the error should only appear
// when we commit again with an invalid input.
// before fix this spec would fail and error were immediately display on $model change.
// The only part I can't explain is why this only happens if there is previous invalid field.
// If you use a single input this will never happen. It might be related with array os reactive
// validation objects re-triggering computations which will not happen in a single input scenario.
await barInput.setValue('')
expect(findBarInputErrors().exists()).toBe(false)

await barInput.trigger('blur')
expect(findBarInputErrors().exists()).toBe(true)
expect(findBarInputErrors().text()).toBe(barIsRequired.$message)
})
})
})

describe('async', () => {
Expand Down