Fix dehydration drops shared statePath when a sibling Group is hidden (#19543)

* Failing test for Repeater: dehydration drops shared statePath when a sibling Group is hidden

* Fix Repeater: dehydration drops shared statePath when a sibling Group is hidden

* cleanup

---------

Co-authored-by: Dan Harrin <git@danharrin.com>
This commit is contained in:
Ben
2026-04-03 15:25:42 +01:00
committed by GitHub
co-authored by Dan Harrin
parent 7d3d3165d3
commit 71ebff1c3e
2 changed files with 481 additions and 0 deletions
@@ -317,6 +317,25 @@ trait HasState
$statePath = $this->getStatePath();
if (! $this->getRootContainer()->hasDehydratedComponent($statePath)) {
// When another component in the same scope shares this
// `statePath`, removing the entire key would destroy that
// sibling's data. Instead, only remove the state paths
// owned by *this* component's descendants.
if ($this->hasComponentWithStatePath($statePath)) {
$descendantStatePathsToForget = $this->getDescendantStatePathsToForget($statePath);
Arr::forget($state, $descendantStatePathsToForget); /** @phpstan-ignore parameterByRef.type */
// Clean up the parent key when nothing meaningful
// remains (e.g. all siblings sharing this path were
// also non-dehydrated and removed their descendants).
if (blank(Arr::get($state, $statePath))) {
Arr::forget($state, $statePath); /** @phpstan-ignore parameterByRef.type */
}
return;
}
Arr::forget($state, $statePath); /** @phpstan-ignore parameterByRef.type */
return;
@@ -347,6 +366,63 @@ trait HasState
}
}
/**
* Check whether another component in the same schema scope shares the
* given absolute `statePath`. The scope is determined by walking up
* through parent containers that don't introduce their own `statePath`
* (e.g. `Section`, `Tabs`) until a `statePath`-bearing ancestor is
* found — that ancestor's container defines the boundary.
*/
protected function hasComponentWithStatePath(string $statePath): bool
{
$container = $this->getContainer();
while ($parentComponent = $container->getParentComponent()) {
if ($parentComponent->hasStatePath()) {
break;
}
$container = $parentComponent->getContainer();
}
foreach ($container->getFlatComponents(withActions: false, withHidden: true) as $component) {
if ($component === $this) {
continue;
}
if ($component->hasStatePath() && $component->getStatePath() === $statePath) {
return true;
}
}
return false;
}
/**
* @return array<string>
*/
protected function getDescendantStatePathsToForget(string $statePath): array
{
$descendantStatePathPrefix = "{$statePath}.";
$paths = [];
foreach ($this->getChildSchemas(withHidden: true) as $childSchema) {
foreach ($childSchema->getFlatComponents(withActions: false, withHidden: true) as $component) {
if (! $component->hasStatePath()) {
continue;
}
$childStatePath = $component->getStatePath();
if (filled($childStatePath) && str_starts_with($childStatePath, $descendantStatePathPrefix)) {
$paths[] = $childStatePath;
}
}
}
return $paths;
}
public function dehydrateStateUsing(?Closure $callback): static
{
$this->dehydrateStateUsing = $callback;
+405
View File
@@ -1155,6 +1155,411 @@ it('can add a new repeater item with nested singular relationship to an existing
expect($newPost->metadata->seo_title)->toBe('New SEO Title');
});
it('preserves visible sibling `Group` state when a hidden `Group` shares the same `statePath`', function (): void {
$user = User::factory()->create();
$existingPost = Post::factory()->create([
'author_id' => $user->id,
]);
$component = livewire(RepeaterSharedStatePathOneHiddenOneVisible::class, ['record' => $user]);
$existingPostKey = array_key_first($component->get('data.posts'));
$undoRepeaterFake = Repeater::fake();
$component
->fillForm([
'posts' => [
$existingPostKey => [
'config' => [
'internal_notes' => 'Do not publish',
'seo_title' => 'New SEO Title',
],
],
],
])
->call('save');
$undoRepeaterFake();
$existingPost->refresh();
expect($existingPost->config)
->not->toHaveKey('internal_notes')
->toHaveKey('seo_title', 'New SEO Title');
});
it('does not save hidden fields when all sibling `Group` components sharing a `statePath` are hidden', function (): void {
$user = User::factory()->create();
$existingPost = Post::factory()->create([
'author_id' => $user->id,
'config' => ['seo_title' => 'Old Title', 'internal_notes' => 'Old Notes'],
]);
$component = livewire(RepeaterSharedStatePathBothHidden::class, ['record' => $user]);
$existingPostKey = array_key_first($component->get('data.posts'));
$undoRepeaterFake = Repeater::fake();
$component
->fillForm([
'posts' => [
$existingPostKey => [
'title' => 'Updated Title',
'config' => [
'internal_notes' => 'Attempted override',
'seo_title' => 'Attempted override',
],
],
],
])
->call('save');
$undoRepeaterFake();
$existingPost->refresh();
// The visible `title` field saves normally.
expect($existingPost->title)->toBe('Updated Title');
// Both `Group` components sharing `config` are hidden, so neither
// field is dehydrated. The original database value is preserved
// because Eloquent's `fill()` does not touch keys absent from
// the dehydrated data.
expect($existingPost->config)
->toBe(['seo_title' => 'Old Title', 'internal_notes' => 'Old Notes']);
});
it('preserves shared `statePath` state when `Group` components are in different `Section` components', function (): void {
$user = User::factory()->create();
$existingPost = Post::factory()->create([
'author_id' => $user->id,
]);
$component = livewire(RepeaterSharedStatePathInsideSection::class, ['record' => $user]);
$existingPostKey = array_key_first($component->get('data.posts'));
$undoRepeaterFake = Repeater::fake();
$component
->fillForm([
'posts' => [
$existingPostKey => [
'config' => [
'internal_notes' => 'Secret',
'seo_title' => 'Visible Title',
],
],
],
])
->call('save');
$undoRepeaterFake();
$existingPost->refresh();
expect($existingPost->config)
->not->toHaveKey('internal_notes')
->toHaveKey('seo_title', 'Visible Title');
});
it('does not leak relationship data into `getState()` return value', function (): void {
$user = User::factory()->create();
Post::factory()->create([
'author_id' => $user->id,
'title' => 'Existing Post',
]);
$component = livewire(RepeaterGetStateLeakCheck::class, ['record' => $user]);
$undoRepeaterFake = Repeater::fake();
$component->call('saveAndCapture');
$undoRepeaterFake();
$captured = $component->get('capturedState');
expect($captured)->not->toHaveKey('posts');
});
it('preserves shared `statePath` state with deeply nested `Group` components', function (): void {
$user = User::factory()->create();
$existingPost = Post::factory()->create([
'author_id' => $user->id,
]);
$component = livewire(RepeaterSharedStatePathDeeplyNested::class, ['record' => $user]);
$existingPostKey = array_key_first($component->get('data.posts'));
$undoRepeaterFake = Repeater::fake();
$component
->fillForm([
'posts' => [
$existingPostKey => [
'config' => [
'admin_note' => 'Hidden deep note',
'seo_title' => 'Deep SEO Title',
],
],
],
])
->call('save');
$undoRepeaterFake();
$existingPost->refresh();
expect($existingPost->config)
->not->toHaveKey('admin_note')
->toHaveKey('seo_title', 'Deep SEO Title');
});
class RepeaterSharedStatePathOneHiddenOneVisible extends Component implements HasSchemas
{
use InteractsWithSchemas;
public array $data = [];
public User $record;
public function mount(): void
{
$this->form->fill([]);
}
public function form(Schema $form): Schema
{
return $form
->schema([
Repeater::make('posts')
->relationship('posts')
->schema([
Group::make([
TextInput::make('internal_notes'),
])
->hidden()
->statePath('config'),
Group::make([
TextInput::make('seo_title'),
])
->statePath('config'),
]),
])
->model($this->record)
->statePath('data');
}
public function save(): void
{
$this->form->getState();
$this->form->saveRelationships();
}
public function render(): View
{
return view('livewire.form');
}
}
class RepeaterSharedStatePathBothHidden extends Component implements HasSchemas
{
use InteractsWithSchemas;
public array $data = [];
public User $record;
public function mount(): void
{
$this->form->fill([]);
}
public function form(Schema $form): Schema
{
return $form
->schema([
Repeater::make('posts')
->relationship('posts')
->schema([
TextInput::make('title'),
Group::make([
TextInput::make('internal_notes'),
])
->hidden()
->statePath('config'),
Group::make([
TextInput::make('seo_title'),
])
->hidden()
->statePath('config'),
]),
])
->model($this->record)
->statePath('data');
}
public function save(): void
{
$this->form->getState();
$this->form->saveRelationships();
}
public function render(): View
{
return view('livewire.form');
}
}
class RepeaterSharedStatePathInsideSection extends Component implements HasSchemas
{
use InteractsWithSchemas;
public array $data = [];
public User $record;
public function mount(): void
{
$this->form->fill([]);
}
public function form(Schema $form): Schema
{
return $form
->schema([
Repeater::make('posts')
->relationship('posts')
->schema([
Section::make('Internal')
->schema([
Group::make([
TextInput::make('internal_notes'),
])
->hidden()
->statePath('config'),
]),
Section::make('Public')
->schema([
Group::make([
TextInput::make('seo_title'),
])
->statePath('config'),
]),
]),
])
->model($this->record)
->statePath('data');
}
public function save(): void
{
$this->form->getState();
$this->form->saveRelationships();
}
public function render(): View
{
return view('livewire.form');
}
}
class RepeaterGetStateLeakCheck extends Component implements HasSchemas
{
use InteractsWithSchemas;
public array $data = [];
public array $capturedState = [];
public User $record;
public function mount(): void
{
$this->form->fill([]);
}
public function form(Schema $form): Schema
{
return $form
->schema([
Repeater::make('posts')
->relationship('posts')
->schema([
TextInput::make('title'),
]),
])
->model($this->record)
->statePath('data');
}
public function saveAndCapture(): void
{
$this->capturedState = $this->form->getState();
$this->form->saveRelationships();
}
public function render(): View
{
return view('livewire.form');
}
}
class RepeaterSharedStatePathDeeplyNested extends Component implements HasSchemas
{
use InteractsWithSchemas;
public array $data = [];
public User $record;
public function mount(): void
{
$this->form->fill([]);
}
public function form(Schema $form): Schema
{
return $form
->schema([
Repeater::make('posts')
->relationship('posts')
->schema([
Group::make([
Group::make([
TextInput::make('admin_note'),
]),
])
->hidden()
->statePath('config'),
Group::make([
Group::make([
TextInput::make('seo_title'),
]),
])
->statePath('config'),
]),
])
->model($this->record)
->statePath('data');
}
public function save(): void
{
$this->form->getState();
$this->form->saveRelationships();
}
public function render(): View
{
return view('livewire.form');
}
}
class RepeaterWithNestedSingularRelationship extends Component implements HasActions, HasSchemas
{
use InteractsWithActions;