From 588d28defefd660c48df03e954ba2fa33ba9e3c9 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 8 Sep 2026 12:51:43 +0100 Subject: [PATCH 1/5] sync original state on terms retrieved from the stache `TaxonomyTermsStore::getItem` overrides `BasicStore::getItem` without syncing the term's original state, so `UpdateTermReferences` saw a null original slug on deletion and left the term's references in entries. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XzL1xhLTSn6kRxAi7UTcm8 --- src/Stache/Stores/TaxonomyTermsStore.php | 4 ++ tests/Listeners/UpdateTermReferencesTest.php | 62 ++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/Stache/Stores/TaxonomyTermsStore.php b/src/Stache/Stores/TaxonomyTermsStore.php index 1d3081d0afd..5e3a0291378 100644 --- a/src/Stache/Stores/TaxonomyTermsStore.php +++ b/src/Stache/Stores/TaxonomyTermsStore.php @@ -73,6 +73,8 @@ public function getItem($key) $this->handleFileChanges(); if ($item = $this->getCachedItem($key)) { + $item->term()->syncOriginal(); + return $item; } @@ -87,6 +89,8 @@ public function getItem($key) ->in($site); } + $item->term()->syncOriginal(); + $this->cacheItem($item); return $item; diff --git a/tests/Listeners/UpdateTermReferencesTest.php b/tests/Listeners/UpdateTermReferencesTest.php index 11f98d0a9ce..b9f4f30dee5 100644 --- a/tests/Listeners/UpdateTermReferencesTest.php +++ b/tests/Listeners/UpdateTermReferencesTest.php @@ -314,6 +314,68 @@ public function it_updates_scoped_term_fields_regardless_of_max_items_setting() $this->assertEquals('topics::norris', $entry->fresh()->get('non_favourites')); } + #[Test] + public function it_nullifies_references_when_deleting_a_term_loaded_from_its_file() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'favourites', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'favourites' => ['hoff', 'norris'], + ]))->save(); + + $this->assertEquals(['hoff', 'norris'], $entry->get('favourites')); + + Facades\Stache::store('terms')->store('topics')->forgetItem('en::hoff'); + + Facades\Term::find('topics::hoff')->delete(); + + $this->assertEquals(['norris'], $entry->fresh()->get('favourites')); + } + + /** @see https://github.com/statamic/cms/issues/11264 */ + #[Test] + public function it_nullifies_references_when_deleting_a_term_that_only_exists_in_entry_data() + { + $collection = tap(Facades\Collection::make('articles')->taxonomies(['topics']))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'topics', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'topics' => ['hoff', 'ghost'], + ]))->save(); + + $this->assertEquals(['hoff', 'ghost'], $entry->get('topics')); + + Facades\Term::find('topics::ghost')->delete(); + + $this->assertEquals(['hoff'], $entry->fresh()->get('topics')); + $this->assertNull(Facades\Term::find('topics::ghost')); + } + #[Test] public function it_nullifies_references_when_deleting_a_scoped_term() { From 85bf3b6e7a8773559a8fee8a54afa84ba7b20548 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 15 Sep 2026 17:32:02 -0400 Subject: [PATCH 2/5] sync original term state when hydrating rather than on every read Syncing on every getItem() mutated the shared cached instance, which clobbered pending changes mid-save: TaxonomyTermsStore::save() looks up the old term to delete its file, and Term::save() looks the term up for its isNew check, both of which re-entered getItem() and reset the original. Renaming a term retrieved from the Stache stopped updating entry references, and isDirty() was false inside TermSaving. Sync in makeItemFromFile() instead, restoring what #5502 removed, and once more on the term built in getItem() before it's cached. Co-Authored-By: Claude Opus 5 (1M context) --- src/Stache/Stores/TaxonomyTermsStore.php | 15 +++--- tests/Listeners/UpdateTermReferencesTest.php | 48 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Stache/Stores/TaxonomyTermsStore.php b/src/Stache/Stores/TaxonomyTermsStore.php index 5e3a0291378..ea860047f88 100644 --- a/src/Stache/Stores/TaxonomyTermsStore.php +++ b/src/Stache/Stores/TaxonomyTermsStore.php @@ -60,6 +60,8 @@ public function makeItemFromFile($path, $contents) $term->dataForLocale($term->defaultLocale(), $data); + $term->syncOriginal(); + return $term; } @@ -73,23 +75,22 @@ public function getItem($key) $this->handleFileChanges(); if ($item = $this->getCachedItem($key)) { - $item->term()->syncOriginal(); - return $item; } [$site, $slug] = explode('::', $key); if ($path = $this->getPath($key)) { - $item = $this->makeItemFromFile($path, File::get($path))->in($site); + $term = $this->makeItemFromFile($path, File::get($path)); } else { - $item = Term::make($slug) + $term = Term::make($slug) ->taxonomy($this->childKey()) - ->set('title', $this->index('title')->get($key)) - ->in($site); + ->set('title', $this->index('title')->get($key)); } - $item->term()->syncOriginal(); + $term->syncOriginal(); + + $item = $term->in($site); $this->cacheItem($item); diff --git a/tests/Listeners/UpdateTermReferencesTest.php b/tests/Listeners/UpdateTermReferencesTest.php index b9f4f30dee5..10d53ae74d8 100644 --- a/tests/Listeners/UpdateTermReferencesTest.php +++ b/tests/Listeners/UpdateTermReferencesTest.php @@ -2,8 +2,10 @@ namespace Tests\Listeners; +use Illuminate\Support\Facades\Event; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Test; +use Statamic\Events\TermSaving; use Statamic\Facades; use Statamic\Support\Arr; use Tests\PreventSavingStacheItemsToDisk; @@ -376,6 +378,52 @@ public function it_nullifies_references_when_deleting_a_term_that_only_exists_in $this->assertNull(Facades\Term::find('topics::ghost')); } + #[Test] + public function it_updates_references_when_renaming_a_term_retrieved_from_the_stache() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'favourite', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'max_items' => 1, + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'favourite' => 'hoff', + ]))->save(); + + $term = Facades\Term::find('topics::hoff'); + $term->slug('hoff-new'); + $term->save(); + + $this->assertEquals('hoff-new', $entry->fresh()->get('favourite')); + } + + #[Test] + public function it_keeps_a_term_retrieved_from_the_stache_dirty_until_it_has_been_saved() + { + $dirty = null; + + Event::listen(TermSaving::class, function ($event) use (&$dirty) { + $dirty = $event->term->isDirty('title'); + }); + + $term = Facades\Term::find('topics::hoff'); + $term->set('title', 'The Hoff'); + $term->save(); + + $this->assertTrue($dirty); + } + #[Test] public function it_nullifies_references_when_deleting_a_scoped_term() { From b15028907c3e382ddbc73b58ebc0ee8d20146ddc Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 15 Sep 2026 17:33:38 -0400 Subject: [PATCH 3/5] backfill original state on items cached before it was synced Cache items are written with forever(), so terms cached by a version that didn't sync their original state persist across an upgrade and come back through the cache hit branch without one. Sync those, but only when it's missing, so a term already in the middle of being saved keeps the original state its pending changes are measured against. Co-Authored-By: Claude Opus 5 (1M context) --- src/Stache/Stores/TaxonomyTermsStore.php | 7 ++++ tests/Listeners/UpdateTermReferencesTest.php | 37 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/Stache/Stores/TaxonomyTermsStore.php b/src/Stache/Stores/TaxonomyTermsStore.php index ea860047f88..24daeca9e2f 100644 --- a/src/Stache/Stores/TaxonomyTermsStore.php +++ b/src/Stache/Stores/TaxonomyTermsStore.php @@ -75,6 +75,13 @@ public function getItem($key) $this->handleFileChanges(); if ($item = $this->getCachedItem($key)) { + // Items cached by an older version won't have had their original state synced when + // they were hydrated, and would never get one. We only backfill it when it's + // missing, since re-syncing would discard changes made to a term being saved. + if (empty($item->term()->getOriginal())) { + $item->term()->syncOriginal(); + } + return $item; } diff --git a/tests/Listeners/UpdateTermReferencesTest.php b/tests/Listeners/UpdateTermReferencesTest.php index 10d53ae74d8..3aa9874ef21 100644 --- a/tests/Listeners/UpdateTermReferencesTest.php +++ b/tests/Listeners/UpdateTermReferencesTest.php @@ -408,6 +408,43 @@ public function it_updates_references_when_renaming_a_term_retrieved_from_the_st $this->assertEquals('hoff-new', $entry->fresh()->get('favourite')); } + #[Test] + public function it_nullifies_references_when_deleting_a_term_cached_without_its_original_state() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'favourites', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'favourites' => ['hoff', 'norris'], + ]))->save(); + + // Mimic an item cached by a version that didn't sync the original state. + $store = Facades\Stache::store('terms')->store('topics'); + $item = Facades\Term::find('topics::hoff'); + (function () { + $this->original = []; + })->call($item->term()); + (function () use ($item) { + $this->cacheItem($item); + })->call($store); + + Facades\Term::find('topics::hoff')->delete(); + + $this->assertEquals(['norris'], $entry->fresh()->get('favourites')); + } + #[Test] public function it_keeps_a_term_retrieved_from_the_stache_dirty_until_it_has_been_saved() { From 12a11485b59dd97701aecf194daa04d64a85a2c8 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 15 Sep 2026 18:24:21 -0400 Subject: [PATCH 4/5] drop the redundant sync in makeItemFromFile getItem() already syncs every term it builds, and terms cached in bulk by paths() get their original state backfilled when they're read, so this was only ever duplicating work. Co-Authored-By: Claude Opus 5 (1M context) --- src/Stache/Stores/TaxonomyTermsStore.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Stache/Stores/TaxonomyTermsStore.php b/src/Stache/Stores/TaxonomyTermsStore.php index 24daeca9e2f..70842f1054e 100644 --- a/src/Stache/Stores/TaxonomyTermsStore.php +++ b/src/Stache/Stores/TaxonomyTermsStore.php @@ -60,8 +60,6 @@ public function makeItemFromFile($path, $contents) $term->dataForLocale($term->defaultLocale(), $data); - $term->syncOriginal(); - return $term; } From 2f8ae94495dd373963e12a2263d0724184469be4 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 16 Sep 2026 17:40:33 -0400 Subject: [PATCH 5/5] correct the comment on the original state backfill Unsynced items don't come from an upgrade, they come from paths(), which caches every localization without syncing on any cold read. Co-Authored-By: Claude Opus 5 (1M context) --- src/Stache/Stores/TaxonomyTermsStore.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Stache/Stores/TaxonomyTermsStore.php b/src/Stache/Stores/TaxonomyTermsStore.php index 70842f1054e..edbba9d6d96 100644 --- a/src/Stache/Stores/TaxonomyTermsStore.php +++ b/src/Stache/Stores/TaxonomyTermsStore.php @@ -73,9 +73,8 @@ public function getItem($key) $this->handleFileChanges(); if ($item = $this->getCachedItem($key)) { - // Items cached by an older version won't have had their original state synced when - // they were hydrated, and would never get one. We only backfill it when it's - // missing, since re-syncing would discard changes made to a term being saved. + // Only sync the original state when it's missing, since re-syncing would + // discard changes made to a term being saved. if (empty($item->term()->getOriginal())) { $item->term()->syncOriginal(); }