fix: Validation hooks mutate value before dehydration (#19300)

* fix: Validation hooks mutate value before dehydration

* Update HasState.php
This commit is contained in:
Dan Harrin
2026-02-19 11:23:16 +00:00
committed by GitHub
parent d5e5a6d06f
commit 4415250dd2
2 changed files with 95 additions and 0 deletions
@@ -284,6 +284,36 @@ trait HasState
return $state;
}
/**
* Takes the raw Livewire state and prunes it to only contain keys
* present in the template array. This preserves the sparse structure
* of validated data while using clean (unmutated) values from Livewire.
*
* @param array<string, mixed> $source
* @param array<string, mixed> $template
* @return array<string, mixed>
*/
protected function pruneStateToMatchKeys(array $source, array $template): array
{
$result = [];
foreach ($template as $key => $templateValue) {
if (! array_key_exists($key, $source)) {
$result[$key] = $templateValue;
continue;
}
if (is_array($templateValue) && is_array($source[$key])) {
$result[$key] = $this->pruneStateToMatchKeys($source[$key], $templateValue);
} else {
$result[$key] = $source[$key];
}
}
return $result;
}
/**
* @param array<string, mixed> | null $state
*/
@@ -417,6 +447,34 @@ trait HasState
return Component::withVisibilityCache(function () use ($shouldCallHooksBefore, $afterValidate): array {
$state = $this->validate();
// `validate()` returns data that went through `prepareForValidation()`,
// which applies `mutateStateForValidation()` mutations. Those mutations
// should only affect validation rules, not the dehydrated output. Replace
// the mutated values with clean Livewire data, preserving the validated
// array's sparse key structure.
$statePath = $this->getStatePath();
$livewire = $this->getLivewire();
if (filled($statePath)) {
$rawState = data_get($livewire, $statePath);
if (is_array($rawState)) {
$validatedFormData = data_get($state, $statePath);
if (is_array($validatedFormData)) {
data_set($state, $statePath, $this->pruneStateToMatchKeys($rawState, $validatedFormData));
}
}
} else {
$rawState = [];
foreach (array_keys($state) as $key) {
$rawState[$key] = data_get($livewire, $key);
}
$state = $this->pruneStateToMatchKeys($rawState, $state);
}
if ($shouldCallHooksBefore) {
$this->callBeforeStateDehydrated($state);
+37
View File
@@ -859,6 +859,43 @@ test('dehydrated state can be mutated when parent schema is hidden and dehydrate
]);
});
test('`mutateStateForValidationUsing()` does not affect the dehydrated state', function (): void {
$schema = Schema::make(Livewire::make())
->statePath('data')
->components([
Field::make($statePath = Str::random())
->default($state = Str::random())
->mutateStateForValidationUsing(fn ($state) => $state . '_MUTATED_FOR_VALIDATION'),
])
->fill();
invade($schema->getLivewire())->cacheSchema('form', $schema);
expect($schema->getState())
->toBe([
$statePath => $state,
]);
});
test('`mutateStateForValidationUsing()` does not affect the dehydrated state when using `dehydrateStateUsing()`', function (): void {
$schema = Schema::make(Livewire::make())
->statePath('data')
->components([
Field::make($statePath = Str::random())
->default($state = Str::random())
->mutateStateForValidationUsing(fn ($state) => $state . '_MUTATED_FOR_VALIDATION')
->dehydrateStateUsing(fn ($state) => $state . '_DEHYDRATED'),
])
->fill();
invade($schema->getLivewire())->cacheSchema('form', $schema);
expect($schema->getState())
->toBe([
$statePath => $state . '_DEHYDRATED',
]);
});
test('sibling state can be retrieved relatively from another component', function (): void {
Schema::make(Livewire::make())
->statePath('data')