From b15936893cb86e474172b49c2a191e5524423031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20R=C3=BCtter?= Date: Thu, 16 Jul 2026 19:29:15 +0200 Subject: [PATCH] Invalidate the old URL when an entry's slug changes The invalidator only built URLs from the entry's current state, so after a slug change the previously cached page kept being served at the old URL. Rebuild the old URI from the original slug and invalidate it, along with anything cached beneath it (descendants, mounted collections). In background recache mode the old URLs are invalidated rather than refreshed, since they no longer resolve and could never be recached. Co-Authored-By: Claude Fable 5 --- src/StaticCaching/DefaultInvalidator.php | 37 +++++- .../StaticCaching/DefaultInvalidatorTest.php | 105 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 3e0ed222c2a..9f548145b40 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -8,6 +8,7 @@ use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Forms\Form; use Statamic\Contracts\Globals\Variables; +use Statamic\Contracts\Routing\UrlBuilder; use Statamic\Contracts\Structures\Nav; use Statamic\Contracts\Structures\NavTree; use Statamic\Facades\Antlers; @@ -37,7 +38,10 @@ public function invalidate($item) return; } - $urls = $this->getItemUrls($item); + $urls = [ + ...$this->getItemUrls($item), + ...$this->getItemOldUrls($item), + ]; $this->cacher->invalidateUrls($urls); } @@ -50,6 +54,11 @@ public function refresh($item) return; } + // Old URLs no longer resolve so they cannot be recached, only invalidated. + if ($oldUrls = $this->getItemOldUrls($item)) { + $this->cacher->invalidateUrls($oldUrls); + } + if ($this->rules === 'all') { $this->cacher->refreshUrls($this->cacher->getUrls()->all()); @@ -88,6 +97,32 @@ protected function getItemUrls($item) return $urls; } + protected function getItemOldUrls($item) + { + return $item instanceof Entry ? $this->getOldEntryUrls($item) : []; + } + + protected function getOldEntryUrls($entry) + { + if ( + is_null($originalSlug = $entry->getOriginal('slug')) + || $originalSlug === $entry->slug() + || ! ($route = $entry->route()) + ) { + return []; + } + + $uri = app(UrlBuilder::class)->content($entry)->merge([ + 'parent_uri' => $entry->parent()?->uri(), + 'slug' => $originalSlug, + ])->build($route); + + $oldUrl = URL::tidy($entry->site()->url().'/'.$uri); + + // Anything cached under the old URL (descendants, mounted collections) is stale too. + return [$oldUrl, Str::finish($oldUrl, '/').'*']; + } + protected function getFormUrls($form) { $rules = collect(Arr::get($this->rules, "forms.{$form->handle()}.urls")); diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index d56fb552f61..a70f24f8028 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -314,6 +314,7 @@ public function collection_urls_can_be_invalidated_by_an_entry() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -368,6 +369,7 @@ public function collection_urls_can_be_invalidated_by_an_entry_in_a_multisite() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::get('fr')); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -417,6 +419,7 @@ public function invalidation_urls_respect_trailing_slash_enforcement() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -456,6 +459,7 @@ public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() $m->shouldReceive('collectionHandle')->andReturn('blog'); $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() @@ -479,6 +483,68 @@ public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() $this->assertNull($invalidator->invalidate($entry)); } + #[Test] + public function old_entry_url_is_invalidated_when_the_slug_changes() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/new-slug', + 'http://localhost/blog/old-slug', + 'http://localhost/blog/old-slug/*', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/blog/new-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->with('slug')->andReturn('old-slug'); + $m->shouldReceive('slug')->andReturn('new-slug'); + $m->shouldReceive('route')->andReturn('/blog/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'new-slug']); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->invalidate($entry)); + } + + #[Test] + public function old_entry_url_is_not_invalidated_when_the_slug_is_unchanged() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/my-slug', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/blog/my-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->with('slug')->andReturn('my-slug'); + $m->shouldReceive('slug')->andReturn('my-slug'); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->invalidate($entry)); + } + #[Test] public function taxonomy_urls_can_be_invalidated() { @@ -961,6 +1027,7 @@ public function it_doesnt_recache_when_background_recache_token_is_disabled() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -1001,6 +1068,7 @@ public function it_recaches_when_background_recache_token_is_enabled() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -1020,4 +1088,41 @@ public function it_recaches_when_background_recache_token_is_enabled() $this->assertNull($invalidator->refresh($entry)); } + + #[Test] + public function old_entry_url_is_invalidated_rather_than_recached_when_background_recache_is_enabled() + { + config()->set('statamic.static_caching.background_recache', true); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->once()->with([ + 'http://localhost/blog/old-slug', + 'http://localhost/blog/old-slug/*', + ]); + $cacher->shouldReceive('refreshUrls')->once()->with([ + 'http://localhost/blog/new-slug', + ]); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/blog/new-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->with('slug')->andReturn('old-slug'); + $m->shouldReceive('slug')->andReturn('new-slug'); + $m->shouldReceive('route')->andReturn('/blog/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'new-slug']); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->refresh($entry)); + } }