From c741ff2d28cb12b1e2027c3b393e7cf0a3308c87 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 18:08:41 +0200 Subject: [PATCH 1/5] Add failing tests for custom Invalidator not firing with background re-cache Fixes #15025 --- .../StaticCaching/DefaultInvalidatorTest.php | 23 +++++++ .../FullMeasureStaticCachingTest.php | 68 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index d56fb552f61..c13315a7892 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -1020,4 +1020,27 @@ public function it_recaches_when_background_recache_token_is_enabled() $this->assertNull($invalidator->refresh($entry)); } + + #[Test] + public function it_calls_the_custom_invalidate_method_when_background_recache_is_enabled() + { + config()->set('statamic.static_caching.background_recache', true); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('refreshUrls')->never(); + $cacher->shouldReceive('invalidateUrls')->once()->with([ + 'http://localhost/custom', + ]); + }); + + $invalidator = new class($cacher, []) extends Invalidator + { + public function invalidate($item) + { + $this->cacher->invalidateUrls(['http://localhost/custom']); + } + }; + + $invalidator->refresh(new \stdClass); + } } diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index 243586f4c14..140ea2c8bb2 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -2,11 +2,17 @@ namespace Tests\StaticCaching; +use Illuminate\Support\Facades\Queue; use PHPUnit\Framework\Attributes\Test; +use Statamic\Console\Commands\StaticWarmJob; +use Statamic\Events\EntrySaved; use Statamic\Facades\File; use Statamic\Facades\StaticCache; use Statamic\StaticCaching\Cacher; +use Statamic\StaticCaching\DefaultInvalidator; +use Statamic\StaticCaching\Invalidate; use Statamic\StaticCaching\NoCache\Session; +use Statamic\Support\Str; use Tests\FakesContent; use Tests\FakesViews; use Tests\PreventSavingStacheItemsToDisk; @@ -243,4 +249,66 @@ public function index() // The page should not be cached. $this->assertFalse(file_exists($this->dir.'/about_.html')); } + + #[Test] + public function custom_invalidator_class_fires_when_background_recache_is_enabled() + { + // https://github.com/statamic/cms/issues/15025 + // + // 1. Enable Full Measure Caching (done in getEnvironmentSetUp) and + // register a Custom Invalidator Class. + // 2. The custom invalidator has a condition where saving the "one" + // entry also invalidates the "/two" page's URL. + // 3. Enable background re-cache. + // 4. Save the "one" entry. + // 5. The relevant files (both "one" and "two") should have been invalidated. + + Queue::fake(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ title }}'); + + $one = $this->createPage('one'); + $this->createPage('two'); + + $this->get('/one')->assertOk(); + $this->get('/two')->assertOk(); + + $this->assertTrue(file_exists($this->dir.'/one_.html')); + $this->assertTrue(file_exists($this->dir.'/two_.html')); + + $customInvalidator = new class(app(Cacher::class)) extends DefaultInvalidator + { + public function invalidate($item) + { + parent::invalidate($item); + + if ($item->slug() === 'one') { + $this->cacher->invalidateUrls(['http://localhost/two']); + } + } + }; + + config()->set('statamic.static_caching.background_recache', true); + + // This mirrors what actually happens on an entry save: the "EntrySaved" + // event is handled by the Invalidate subscriber, which calls refresh() + // on the configured Invalidator. + $invalidate = new Invalidate($customInvalidator, app(Cacher::class)); + $invalidate->refreshEntry(new EntrySaved($one)); + + // The entry's own page should always get recached. + Queue::assertPushed(StaticWarmJob::class, function ($job) { + return Str::startsWith((string) $job->request->getUri(), 'http://localhost/one'); + }); + + // Per our custom Invalidator's condition, saving "one" should have + // also caused "/two" to be recached. It currently doesn't, because + // DefaultInvalidator::refresh() bypasses invalidate() entirely when + // background_recache is enabled. + Queue::assertPushed(StaticWarmJob::class, function ($job) { + return Str::startsWith((string) $job->request->getUri(), 'http://localhost/two'); + }); + } } From 0287a7f82e8c900a0cef9b3fa2b4e987998680d4 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 18:23:27 +0200 Subject: [PATCH 2/5] Route refresh() through invalidate() so custom Invalidators fire during background re-cache Fixes #15025 --- src/StaticCaching/DefaultInvalidator.php | 19 ++++++++++--------- .../FullMeasureStaticCachingTest.php | 8 +++----- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 3e0ed222c2a..97012de07cd 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -22,6 +22,7 @@ class DefaultInvalidator implements Invalidator { protected $cacher; protected $rules; + protected $refreshing = false; public function __construct(Cacher $cacher, $rules = []) { @@ -32,14 +33,18 @@ public function __construct(Cacher $cacher, $rules = []) public function invalidate($item) { if ($this->rules === 'all') { - $this->cacher->flush(); + $this->refreshing + ? $this->cacher->refreshUrls($this->cacher->getUrls()->all()) + : $this->cacher->flush(); return; } $urls = $this->getItemUrls($item); - $this->cacher->invalidateUrls($urls); + $this->refreshing + ? $this->cacher->refreshUrls($urls) + : $this->cacher->invalidateUrls($urls); } public function refresh($item) @@ -50,15 +55,11 @@ public function refresh($item) return; } - if ($this->rules === 'all') { - $this->cacher->refreshUrls($this->cacher->getUrls()->all()); + $this->refreshing = true; - return; - } - - $urls = $this->getItemUrls($item); + $this->invalidate($item); - $this->cacher->refreshUrls($urls); + $this->refreshing = false; } protected function getItemUrls($item) diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index 140ea2c8bb2..5ba6e88eb99 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -298,17 +298,15 @@ public function invalidate($item) $invalidate = new Invalidate($customInvalidator, app(Cacher::class)); $invalidate->refreshEntry(new EntrySaved($one)); - // The entry's own page should always get recached. + // The entry's own page should get recached (soft, via the warm queue). Queue::assertPushed(StaticWarmJob::class, function ($job) { return Str::startsWith((string) $job->request->getUri(), 'http://localhost/one'); }); // Per our custom Invalidator's condition, saving "one" should have - // also caused "/two" to be recached. It currently doesn't, because + // also hard-invalidated "/two". It currently doesn't, because // DefaultInvalidator::refresh() bypasses invalidate() entirely when // background_recache is enabled. - Queue::assertPushed(StaticWarmJob::class, function ($job) { - return Str::startsWith((string) $job->request->getUri(), 'http://localhost/two'); - }); + $this->assertFalse(file_exists($this->dir.'/two_.html')); } } From c54f123dee108a795fc85e1e79065d6733de2c3d Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 18:25:00 +0200 Subject: [PATCH 3/5] Drop the end-to-end repro test, unit test already covers the fix --- .../FullMeasureStaticCachingTest.php | 66 ------------------- 1 file changed, 66 deletions(-) diff --git a/tests/StaticCaching/FullMeasureStaticCachingTest.php b/tests/StaticCaching/FullMeasureStaticCachingTest.php index 5ba6e88eb99..243586f4c14 100644 --- a/tests/StaticCaching/FullMeasureStaticCachingTest.php +++ b/tests/StaticCaching/FullMeasureStaticCachingTest.php @@ -2,17 +2,11 @@ namespace Tests\StaticCaching; -use Illuminate\Support\Facades\Queue; use PHPUnit\Framework\Attributes\Test; -use Statamic\Console\Commands\StaticWarmJob; -use Statamic\Events\EntrySaved; use Statamic\Facades\File; use Statamic\Facades\StaticCache; use Statamic\StaticCaching\Cacher; -use Statamic\StaticCaching\DefaultInvalidator; -use Statamic\StaticCaching\Invalidate; use Statamic\StaticCaching\NoCache\Session; -use Statamic\Support\Str; use Tests\FakesContent; use Tests\FakesViews; use Tests\PreventSavingStacheItemsToDisk; @@ -249,64 +243,4 @@ public function index() // The page should not be cached. $this->assertFalse(file_exists($this->dir.'/about_.html')); } - - #[Test] - public function custom_invalidator_class_fires_when_background_recache_is_enabled() - { - // https://github.com/statamic/cms/issues/15025 - // - // 1. Enable Full Measure Caching (done in getEnvironmentSetUp) and - // register a Custom Invalidator Class. - // 2. The custom invalidator has a condition where saving the "one" - // entry also invalidates the "/two" page's URL. - // 3. Enable background re-cache. - // 4. Save the "one" entry. - // 5. The relevant files (both "one" and "two") should have been invalidated. - - Queue::fake(); - - $this->withFakeViews(); - $this->viewShouldReturnRaw('layout', '{{ template_content }}'); - $this->viewShouldReturnRaw('default', '{{ title }}'); - - $one = $this->createPage('one'); - $this->createPage('two'); - - $this->get('/one')->assertOk(); - $this->get('/two')->assertOk(); - - $this->assertTrue(file_exists($this->dir.'/one_.html')); - $this->assertTrue(file_exists($this->dir.'/two_.html')); - - $customInvalidator = new class(app(Cacher::class)) extends DefaultInvalidator - { - public function invalidate($item) - { - parent::invalidate($item); - - if ($item->slug() === 'one') { - $this->cacher->invalidateUrls(['http://localhost/two']); - } - } - }; - - config()->set('statamic.static_caching.background_recache', true); - - // This mirrors what actually happens on an entry save: the "EntrySaved" - // event is handled by the Invalidate subscriber, which calls refresh() - // on the configured Invalidator. - $invalidate = new Invalidate($customInvalidator, app(Cacher::class)); - $invalidate->refreshEntry(new EntrySaved($one)); - - // The entry's own page should get recached (soft, via the warm queue). - Queue::assertPushed(StaticWarmJob::class, function ($job) { - return Str::startsWith((string) $job->request->getUri(), 'http://localhost/one'); - }); - - // Per our custom Invalidator's condition, saving "one" should have - // also hard-invalidated "/two". It currently doesn't, because - // DefaultInvalidator::refresh() bypasses invalidate() entirely when - // background_recache is enabled. - $this->assertFalse(file_exists($this->dir.'/two_.html')); - } } From 8dd409d55296c1ddc84c7d3fd1551197bd5232a8 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 19:13:14 +0200 Subject: [PATCH 4/5] Make DefaultInvalidator refresh() exception-safe and reentrant --- src/StaticCaching/DefaultInvalidator.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 97012de07cd..70a909a6fe6 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -22,6 +22,9 @@ class DefaultInvalidator implements Invalidator { protected $cacher; protected $rules; + + // Read by invalidate() to pick refreshUrls() over invalidateUrls()/flush(). Subclasses + // overriding invalidate() must check this themselves to get refresh-vs-invalidate semantics. protected $refreshing = false; public function __construct(Cacher $cacher, $rules = []) @@ -55,11 +58,14 @@ public function refresh($item) return; } + $previous = $this->refreshing; $this->refreshing = true; - $this->invalidate($item); - - $this->refreshing = false; + try { + $this->invalidate($item); + } finally { + $this->refreshing = $previous; + } } protected function getItemUrls($item) From ef2beea846ffe00deddd07c86dca813d2f8b7079 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Sat, 18 Jul 2026 19:22:10 +0200 Subject: [PATCH 5/5] Drop comment --- src/StaticCaching/DefaultInvalidator.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 70a909a6fe6..6a77d450597 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -22,9 +22,6 @@ class DefaultInvalidator implements Invalidator { protected $cacher; protected $rules; - - // Read by invalidate() to pick refreshUrls() over invalidateUrls()/flush(). Subclasses - // overriding invalidate() must check this themselves to get refresh-vs-invalidate semantics. protected $refreshing = false; public function __construct(Cacher $cacher, $rules = [])