From 1103689b2e366fd7113fd20937f903bcf3862bc2 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 10 Sep 2026 16:17:49 -0400 Subject: [PATCH] Give reference updater fields the item being updated as their parent Blueprints hand back a Fields instance cached globally by handle, and nested fields are constructed without a parent at all, so neither can be relied upon to have the right one during a reference update. Clone the fields rather than mutating them in place, so the item doesn't leak into every other consumer of that blueprint for the rest of the request. Co-Authored-By: Claude Opus 5 (1M context) --- src/Data/DataReferenceUpdater.php | 20 +++- tests/Data/DataReferenceUpdaterTest.php | 120 ++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/Data/DataReferenceUpdaterTest.php diff --git a/src/Data/DataReferenceUpdater.php b/src/Data/DataReferenceUpdater.php index 8f861c90125..5f9f49a0caa 100644 --- a/src/Data/DataReferenceUpdater.php +++ b/src/Data/DataReferenceUpdater.php @@ -85,7 +85,23 @@ public function updateReferences($originalValue, $newValue) */ protected function getTopLevelFields() { - return $this->item->blueprint()->fields()->all(); + return $this->fieldsWithItemAsParent($this->item->blueprint()->fields()); + } + + /** + * While updating references, the parent of every field is the item being updated. + * + * Blueprints hand back a Fields instance that's cached globally by handle, and nested fields + * are constructed without a parent at all, so neither can be relied upon to have the right + * one. The fields are cloned rather than mutated in place, otherwise the item would leak + * into every other consumer of that blueprint for the rest of the request. + * + * @param \Statamic\Fields\Fields $fields + * @return \Illuminate\Support\Collection + */ + private function fieldsWithItemAsParent($fields) + { + return $fields->all()->map(fn ($field) => (clone $field)->setParent($this->item)); } /** @@ -120,7 +136,7 @@ protected function fieldsWithReferenceUpdates($fields) */ public function processNestedFields($fields, $dottedPrefix): void { - $this->recursivelyUpdateFields($fields->all(), $dottedPrefix); + $this->recursivelyUpdateFields($this->fieldsWithItemAsParent($fields), $dottedPrefix); } /** diff --git a/tests/Data/DataReferenceUpdaterTest.php b/tests/Data/DataReferenceUpdaterTest.php new file mode 100644 index 00000000000..0d7c986710f --- /dev/null +++ b/tests/Data/DataReferenceUpdaterTest.php @@ -0,0 +1,120 @@ +save(); + } + + #[Test] + public function it_gives_top_level_fields_the_item_being_updated_as_their_parent() + { + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + ['handle' => 'hero', 'field' => ['type' => 'parent_recorder']], + ], + ]); + + $one = tap(Facades\Entry::make()->collection('articles')->slug('one')->data(['hero' => 'hoff.jpg']))->save(); + $two = tap(Facades\Entry::make()->collection('articles')->slug('two')->data(['hero' => 'hoff.jpg']))->save(); + + $this->updateReferences($one); + $this->updateReferences($two); + + $this->assertCount(2, ParentRecorderFieldtype::$parents); + $this->assertSame($one, ParentRecorderFieldtype::$parents[0]); + $this->assertSame($two, ParentRecorderFieldtype::$parents[1]); + } + + #[Test] + public function it_gives_nested_fields_the_item_being_updated_as_their_parent() + { + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'grid', + 'field' => [ + 'type' => 'grid', + 'fields' => [ + ['handle' => 'hero', 'field' => ['type' => 'parent_recorder']], + ], + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection('articles')->slug('one')->data([ + 'grid' => [['hero' => 'hoff.jpg']], + ]))->save(); + + $this->updateReferences($entry); + + $this->assertCount(1, ParentRecorderFieldtype::$parents); + $this->assertSame($entry, ParentRecorderFieldtype::$parents[0]); + } + + #[Test] + public function it_doesnt_leave_the_item_on_the_blueprints_shared_fields() + { + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + ['handle' => 'hero', 'field' => ['type' => 'parent_recorder']], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection('articles')->slug('one')->data(['hero' => 'hoff.jpg']))->save(); + + $blueprint = Facades\Collection::find('articles')->entryBlueprint(); + $blueprint->setParent(null); + + $this->updateReferences($entry); + + $this->assertNull($blueprint->fields()->get('hero')->parent()); + } + + private function setInBlueprints($namespace, $blueprintContents) + { + $blueprint = tap(Facades\Blueprint::make('set-in-blueprints')->setContents($blueprintContents))->save(); + + Facades\Blueprint::shouldReceive('in')->with($namespace)->andReturn(collect([$blueprint])); + } + + private function updateReferences($item) + { + AssetReferenceUpdater::item($item) + ->filterByContainer('test_container') + ->updateReferences('hoff.jpg', 'norris.jpg'); + } +} + +class ParentRecorderFieldtype extends Fieldtype +{ + use UpdatesReferences; + + public static $parents = []; + + public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) + { + static::$parents[] = $this->field->parent(); + + return $data; + } +}