diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 3a846b445b1..9861271b12c 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -2,18 +2,21 @@ namespace Statamic\StaticCaching; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection as IlluminateCollection; use Statamic\Contracts\Assets\Asset; use Statamic\Contracts\Entries\Collection; 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; use Statamic\Facades\Antlers; use Statamic\Facades\Site; use Statamic\Facades\URL; +use Statamic\Statamic; use Statamic\Structures\CollectionTree; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -33,6 +36,11 @@ public function __construct(Cacher $cacher, $rules = []) public function invalidate($item) { + // Old URLs no longer resolve so they cannot be recached, only invalidated. + if ($this->refreshing && ($oldUrls = $this->getItemOldUrls($item))) { + $this->cacher->invalidateUrls($oldUrls); + } + if ($this->rules === 'all') { $this->refreshing ? $this->cacher->refreshUrls($this->cacher->getUrls()->all()) @@ -45,7 +53,7 @@ public function invalidate($item) $this->refreshing ? $this->cacher->refreshUrls($urls) - : $this->cacher->invalidateUrls($urls); + : $this->cacher->invalidateUrls([...$urls, ...$this->getItemOldUrls($item)]); } public function refresh($item) @@ -93,6 +101,68 @@ protected function getItemUrls($item) return $urls; } + protected function getItemOldUrls($item) + { + return $item instanceof Entry ? $this->getOldEntryUrls($item) : []; + } + + protected function getOldEntryUrls($entry) + { + if (! ($route = $entry->route())) { + return []; + } + + // The route can reference any field, e.g. {year}/{month}/{day}/{slug}. + $original = collect(Antlers::identifiers($this->convertToAntlers($route))) + ->filter(fn ($identifier) => $this->routeIdentifierIsDirty($entry, $identifier)) + ->mapWithKeys(fn ($identifier) => [$identifier => $this->originalRouteValue($entry, $identifier)]) + ->filter(fn ($value) => ! is_null($value)); + + if ($original->isEmpty()) { + return []; + } + + $uri = app(UrlBuilder::class)->content($entry)->merge([ + 'parent_uri' => $entry->parent()?->uri(), + ...$original->all(), + ])->build($route); + + $oldUrl = URL::tidy($entry->site()->absoluteUrl().'/'.$uri); + + if ($oldUrl === $entry->absoluteUrl()) { + return []; + } + + // Anything cached under the old URL (descendants, mounted collections) is stale too. + return [$oldUrl, Str::finish($oldUrl, '/').'*']; + } + + private function routeIdentifierIsDirty($entry, $identifier) + { + return $entry->isDirty(in_array($identifier, ['year', 'month', 'day']) ? 'date' : $identifier); + } + + private function originalRouteValue($entry, $identifier) + { + if (! in_array($identifier, ['year', 'month', 'day', 'date'])) { + return $entry->getOriginal($identifier); + } + + if (is_null($original = $entry->getOriginal('date'))) { + return null; + } + + $date = Carbon::createFromFormat('Y-m-d-Hi', $original, $entry->date()?->timezone) + ->setTimezone(Statamic::displayTimezone()); + + return match ($identifier) { + 'year' => $date->format('Y'), + 'month' => $date->format('m'), + 'day' => $date->format('d'), + 'date' => $date, + }; + } + 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 b3c5d014861..e56eb36599f 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -2,6 +2,7 @@ namespace Tests\StaticCaching; +use Illuminate\Support\Carbon; use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Assets\Asset; @@ -17,6 +18,7 @@ use Statamic\Facades\Site; use Statamic\Facades\URL; use Statamic\Globals\Variables; +use Statamic\Sites\Site as SiteModel; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\DefaultInvalidator as Invalidator; use Statamic\Structures\CollectionTree; @@ -368,6 +370,8 @@ 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('route')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -422,6 +426,8 @@ 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('route')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -471,6 +477,8 @@ 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('route')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -510,6 +518,8 @@ 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('route')->andReturnNull(); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() @@ -533,6 +543,221 @@ 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('isDirty')->with('slug')->andReturn(true); + $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('route')->andReturn('/blog/{slug}'); + $m->shouldReceive('isDirty')->with('slug')->andReturn(false); + $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_invalidated_when_a_non_slug_route_field_changes() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/2025/my-post', + 'http://localhost/2024/my-post', + 'http://localhost/2024/my-post/*', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/2025/my-post'); + $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-post'); + $m->shouldReceive('getOriginal')->with('date')->andReturn('2024-01-15-1200'); + $m->shouldReceive('slug')->andReturn('my-post'); + $m->shouldReceive('date')->andReturn(Carbon::parse('2025-06-01', 'UTC')); + $m->shouldReceive('route')->andReturn('/{year}/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'my-post', 'year' => '2025']); + $m->shouldReceive('isDirty')->with('slug')->andReturn(false); + $m->shouldReceive('isDirty')->with('date')->andReturn(true); + $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_built_from_the_sites_absolute_url_not_its_configured_url() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://example.test/blog/new-slug', + 'http://example.test/blog/old-slug', + 'http://example.test/blog/old-slug/*', + ])->once(); + }); + + $site = tap(Mockery::mock(SiteModel::class), function ($m) { + // Configured url() is relative, as it is on a default install. + // absoluteUrl() must be used to build a comparable/cacheable URL. + $m->shouldReceive('url')->andReturn('/'); + $m->shouldReceive('absoluteUrl')->andReturn('http://example.test'); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) use ($site) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://example.test/blog/new-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn($site); + $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('isDirty')->with('slug')->andReturn(true); + $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_a_dirty_route_field_reconstructs_to_the_same_url() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/2025/my-post', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/2025/my-post'); + $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-post'); + $m->shouldReceive('getOriginal')->with('date')->andReturn('2025-01-05-1200'); + $m->shouldReceive('slug')->andReturn('my-post'); + $m->shouldReceive('date')->andReturn(Carbon::parse('2025-02-20', 'UTC')); + $m->shouldReceive('route')->andReturn('/{year}/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'my-post', 'year' => '2025']); + $m->shouldReceive('isDirty')->with('slug')->andReturn(false); + $m->shouldReceive('isDirty')->with('date')->andReturn(true); + $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_rebuilt_from_originals_of_all_dirty_route_fields() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/2025/new-post', + 'http://localhost/2024/old-post', + 'http://localhost/2024/old-post/*', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/2025/new-post'); + $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-post'); + $m->shouldReceive('getOriginal')->with('date')->andReturn('2024-03-10-0900'); + $m->shouldReceive('slug')->andReturn('new-post'); + $m->shouldReceive('date')->andReturn(Carbon::parse('2025-01-01', 'UTC')); + $m->shouldReceive('route')->andReturn('/{year}/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'new-post', 'year' => '2025']); + $m->shouldReceive('isDirty')->with('slug')->andReturn(true); + $m->shouldReceive('isDirty')->with('date')->andReturn(true); + $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() { @@ -1015,6 +1240,8 @@ 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('route')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -1055,6 +1282,8 @@ 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('route')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -1075,6 +1304,44 @@ 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('isDirty')->with('slug')->andReturn(true); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->refresh($entry)); + } + #[Test] public function it_calls_the_custom_invalidate_method_when_background_recache_is_enabled() {