From 0120900e6d4a0eb0483a40d45f86a480f4af28e5 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 22 May 2026 10:23:15 +0100 Subject: [PATCH] [6.x] Fix static cache invalidation stripping trailing slashes When trailing slash enforcement is enabled via `URL::enforceTrailingSlashes()`, custom invalidation rules were failing to clear the cache because the invalidator was explicitly stripping trailing slashes with `withTrailingSlash: false`. This caused a mismatch: the URL index stores paths with trailing slashes (e.g., `/events/`) but the invalidator was generating paths without them (e.g., `/events`), causing exact-match lookups to fail. The fix removes the explicit `withTrailingSlash: false` argument from all `URL::tidy()` calls, allowing them to respect the global trailing slash setting. Fixes #14701 Co-Authored-By: Claude Opus 4.5 --- src/StaticCaching/DefaultInvalidator.php | 18 +++---- .../StaticCaching/DefaultInvalidatorTest.php | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 9ee4d46e419..3e0ed222c2a 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -97,7 +97,7 @@ protected function getFormUrls($form) $prefixedRelativeUrls = Site::all()->map(function ($site) use ($rules) { return $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($site->url().'/'.$rule, withTrailingSlash: false)); + ->map(fn (string $rule) => URL::tidy($site->url().'/'.$rule)); })->flatten()->all(); return [ @@ -115,7 +115,7 @@ protected function getAssetUrls($asset) $prefixedRelativeUrls = Site::all()->map(function ($site) use ($rules) { return $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($site->url().'/'.$rule, withTrailingSlash: false)); + ->map(fn (string $rule) => URL::tidy($site->url().'/'.$rule)); })->flatten()->all(); return [ @@ -141,7 +141,7 @@ protected function getEntryUrls($entry) $prefixedRelativeUrls = $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($entry->site()->url().'/'.$rule, withTrailingSlash: false)) + ->map(fn (string $rule) => URL::tidy($entry->site()->url().'/'.$rule)) ->all(); return [ @@ -170,7 +170,7 @@ protected function getTermUrls($term) $prefixedRelativeUrls = $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($term->site()->url().'/'.$rule, withTrailingSlash: false)) + ->map(fn (string $rule) => URL::tidy($term->site()->url().'/'.$rule)) ->all(); return [ @@ -192,7 +192,7 @@ protected function getNavUrls($nav) $prefixedRelativeUrls = $nav->sites()->map(function ($site) use ($rules) { return $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy(Site::get($site)->url().'/'.$rule, withTrailingSlash: false)); + ->map(fn (string $rule) => URL::tidy(Site::get($site)->url().'/'.$rule)); })->flatten()->all(); return [ @@ -212,7 +212,7 @@ protected function getNavTreeUrls($tree) $prefixedRelativeUrls = $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($tree->site()->url().'/'.$rule, withTrailingSlash: false)) + ->map(fn (string $rule) => URL::tidy($tree->site()->url().'/'.$rule)) ->all(); return [ @@ -232,7 +232,7 @@ protected function getGlobalUrls($variables) $prefixedRelativeUrls = $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($variables->site()->url().'/'.$rule, withTrailingSlash: false)) + ->map(fn (string $rule) => URL::tidy($variables->site()->url().'/'.$rule)) ->all(); return [ @@ -252,7 +252,7 @@ protected function getCollectionUrls($collection) $prefixedRelativeUrls = $collection->sites()->map(function ($site) use ($rules) { return $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy(Site::get($site)->url().'/'.$rule, withTrailingSlash: false)); + ->map(fn (string $rule) => URL::tidy(Site::get($site)->url().'/'.$rule)); })->flatten()->all(); return [ @@ -270,7 +270,7 @@ protected function getCollectionTreeUrls($tree) $prefixedRelativeUrls = $rules ->reject(fn (string $rule) => URL::isAbsolute($rule)) - ->map(fn (string $rule) => URL::tidy($tree->site()->url().'/'.$rule, withTrailingSlash: false)) + ->map(fn (string $rule) => URL::tidy($tree->site()->url().'/'.$rule)) ->all(); return [ diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index 70d22de54f9..aef6425ffa9 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -14,6 +14,7 @@ use Statamic\Contracts\Taxonomies\Taxonomy; use Statamic\Contracts\Taxonomies\Term; use Statamic\Facades\Site; +use Statamic\Facades\URL; use Statamic\Globals\Variables; use Statamic\StaticCaching\Cacher; use Statamic\StaticCaching\DefaultInvalidator as Invalidator; @@ -388,6 +389,52 @@ public function collection_urls_can_be_invalidated_by_an_entry_in_a_multisite() $this->assertNull($invalidator->invalidate($entry)); } + #[Test] + public function invalidation_urls_respect_trailing_slash_enforcement() + { + URL::enforceTrailingSlashes(); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://test.com/my/test/entry/', + 'http://localhost/blog/three/', + 'http://localhost/blog/one/', + 'http://localhost/blog/two/', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://test.com/my/test/entry/'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect([ + 'parent_uri' => '/my/test/', + ])); + }); + + $invalidator = new Invalidator($cacher, [ + 'collections' => [ + 'blog' => [ + 'urls' => [ + '/blog/one/', + '/blog/two/', + 'http://localhost/blog/three/', + ], + ], + ], + ]); + + $this->assertNull($invalidator->invalidate($entry)); + + URL::enforceTrailingSlashes(false); + } + #[Test] public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() {