From 0120900e6d4a0eb0483a40d45f86a480f4af28e5 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 22 May 2026 10:23:15 +0100 Subject: [PATCH 1/2] [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() { From 06efdb7f3211c71d86efa5a0fe8502dd21baefa8 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 3 Jun 2026 16:25:46 -0400 Subject: [PATCH 2/2] Clean up trailing slash enforcement test Move URL static state reset into tearDown() so it runs even on failure, and use rules without trailing slashes to better illustrate the bug scenario. Co-Authored-By: Claude Sonnet 4.6 --- tests/StaticCaching/DefaultInvalidatorTest.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index aef6425ffa9..d56fb552f61 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -26,6 +26,13 @@ class DefaultInvalidatorTest extends TestCase { + public function tearDown(): void + { + URL::enforceTrailingSlashes(false); + URL::clearUrlCache(); + parent::tearDown(); + } + #[Test] public function specifying_all_as_invalidation_rule_will_just_flush_the_cache() { @@ -422,8 +429,8 @@ public function invalidation_urls_respect_trailing_slash_enforcement() 'collections' => [ 'blog' => [ 'urls' => [ - '/blog/one/', - '/blog/two/', + '/blog/one', + '/blog/two', 'http://localhost/blog/three/', ], ], @@ -431,8 +438,6 @@ public function invalidation_urls_respect_trailing_slash_enforcement() ]); $this->assertNull($invalidator->invalidate($entry)); - - URL::enforceTrailingSlashes(false); } #[Test]