From e6f7f2c4c20e9929c8bc0773679516660e72241e Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Thu, 15 Oct 2026 18:30:10 -0500 Subject: [PATCH 1/7] Include the modification time in the CMS Twig cache key Twig names the compiled template class after the cache key and skips its freshness check once the class is loaded. The key was the file path alone, so a long-running process that had rendered an object kept rendering the old version after the object was edited. --- modules/cms/classes/CmsObject.php | 7 ++++++- modules/cms/tests/classes/CmsObjectTest.php | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/modules/cms/classes/CmsObject.php b/modules/cms/classes/CmsObject.php index 4411d61d94..2b56431d43 100644 --- a/modules/cms/classes/CmsObject.php +++ b/modules/cms/classes/CmsObject.php @@ -302,11 +302,16 @@ public function getTwigContent() /** * Returns the key used by the Twig cache. + * + * Twig names the compiled template class after this key and skips its freshness check once that + * class is loaded, so the key includes the modification time: otherwise a process that has + * rendered the object keeps rendering the old version after it is edited. + * * @return string */ public function getTwigCacheKey() { - $key = $this->getFilePath(); + $key = $this->getFilePath() . '?' . $this->mtime; if ($event = $this->fireEvent('cmsObject.getTwigCacheKey', compact('key'), true)) { $key = $event; diff --git a/modules/cms/tests/classes/CmsObjectTest.php b/modules/cms/tests/classes/CmsObjectTest.php index 02b83c1170..2099c4bc48 100644 --- a/modules/cms/tests/classes/CmsObjectTest.php +++ b/modules/cms/tests/classes/CmsObjectTest.php @@ -335,4 +335,19 @@ public function testSaveNewDir() $this->assertFileExists($destFilePath); $this->assertEquals($testContents, file_get_contents($destFilePath)); } + + public function testEditedObjectRendersItsNewContentInTheSameProcess() + { + $obj = TestCmsObject::load(Theme::load('test'), 'plain.html'); + $loader = new \Cms\Twig\Loader(); + $loader->setObject($obj); + $twig = new \Twig\Environment($loader, ['cache' => false]); + + $this->assertSame($obj->content, $twig->load($obj->getFilePath())->render([])); + + $obj->content = '

Edited.

'; + $obj->mtime = $obj->mtime + 1; + + $this->assertSame('

Edited.

', $twig->load($obj->getFilePath())->render([])); + } } From 2c383e3c02518fa82f88f993ab6ff7d00952f827 Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Thu, 15 Oct 2026 18:39:07 -0500 Subject: [PATCH 2/7] Drop the added getTwigCacheKey docblock lines --- modules/cms/classes/CmsObject.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/modules/cms/classes/CmsObject.php b/modules/cms/classes/CmsObject.php index 2b56431d43..7a8c263865 100644 --- a/modules/cms/classes/CmsObject.php +++ b/modules/cms/classes/CmsObject.php @@ -302,11 +302,6 @@ public function getTwigContent() /** * Returns the key used by the Twig cache. - * - * Twig names the compiled template class after this key and skips its freshness check once that - * class is loaded, so the key includes the modification time: otherwise a process that has - * rendered the object keeps rendering the old version after it is edited. - * * @return string */ public function getTwigCacheKey() From 9bfbf1708e8ce8fcdd1168a80169e1a42f51e975 Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Thu, 15 Oct 2026 23:20:28 -0500 Subject: [PATCH 3/7] Persist the edit and use a cached Twig environment in the cache key test --- modules/cms/tests/classes/CmsObjectTest.php | 44 +++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/modules/cms/tests/classes/CmsObjectTest.php b/modules/cms/tests/classes/CmsObjectTest.php index 2099c4bc48..7206cf3b46 100644 --- a/modules/cms/tests/classes/CmsObjectTest.php +++ b/modules/cms/tests/classes/CmsObjectTest.php @@ -5,7 +5,11 @@ use System\Tests\Bootstrap\TestCase; use Cms\Classes\CmsObject; use Cms\Classes\Theme; +use Cms\Twig\Loader as TwigLoader; use PHPUnit\Framework\Attributes\Depends; +use Twig\Cache\FilesystemCache as TwigCacheFilesystem; +use Twig\Environment as TwigEnvironment; +use Winter\Storm\Support\Facades\File; class TestCmsObject extends CmsObject { @@ -338,16 +342,34 @@ public function testSaveNewDir() public function testEditedObjectRendersItsNewContentInTheSameProcess() { - $obj = TestCmsObject::load(Theme::load('test'), 'plain.html'); - $loader = new \Cms\Twig\Loader(); - $loader->setObject($obj); - $twig = new \Twig\Environment($loader, ['cache' => false]); - - $this->assertSame($obj->content, $twig->load($obj->getFilePath())->render([])); - - $obj->content = '

Edited.

'; - $obj->mtime = $obj->mtime + 1; - - $this->assertSame('

Edited.

', $twig->load($obj->getFilePath())->render([])); + $theme = Theme::load('test'); + $filePath = $theme->getPath() . '/temporary/twig-cache-key.htm'; + $cachePath = storage_path('temp/twig-cache-key-test'); + file_put_contents($filePath, '

Original.

'); + + try { + $loader = new TwigLoader(); + $twig = new TwigEnvironment($loader, [ + 'auto_reload' => true, + 'cache' => new TwigCacheFilesystem($cachePath), + ]); + + $obj = TestTemporaryCmsObject::load($theme, 'twig-cache-key.htm'); + $loader->setObject($obj); + $this->assertSame('

Original.

', $twig->load($obj->getFilePath())->render([])); + + $obj->content = '

Edited.

'; + $obj->save(); + touch($filePath, $obj->mtime + 10); + clearstatcache(); + + CmsObject::clearInternalCache(); + $obj = TestTemporaryCmsObject::load($theme, 'twig-cache-key.htm'); + $loader->setObject($obj); + $this->assertSame('

Edited.

', $twig->load($obj->getFilePath())->render([])); + } finally { + @unlink($filePath); + File::deleteDirectory($cachePath); + } } } From daef38947571affe8d83707262186602d7508779 Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Thu, 15 Oct 2026 23:24:48 -0500 Subject: [PATCH 4/7] Key CMS Twig templates by a hash of their content in the loader --- modules/cms/classes/CmsObject.php | 2 +- modules/cms/tests/classes/CmsObjectTest.php | 1 - modules/cms/twig/Loader.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/cms/classes/CmsObject.php b/modules/cms/classes/CmsObject.php index 7a8c263865..4411d61d94 100644 --- a/modules/cms/classes/CmsObject.php +++ b/modules/cms/classes/CmsObject.php @@ -306,7 +306,7 @@ public function getTwigContent() */ public function getTwigCacheKey() { - $key = $this->getFilePath() . '?' . $this->mtime; + $key = $this->getFilePath(); if ($event = $this->fireEvent('cmsObject.getTwigCacheKey', compact('key'), true)) { $key = $event; diff --git a/modules/cms/tests/classes/CmsObjectTest.php b/modules/cms/tests/classes/CmsObjectTest.php index 7206cf3b46..cb760bf206 100644 --- a/modules/cms/tests/classes/CmsObjectTest.php +++ b/modules/cms/tests/classes/CmsObjectTest.php @@ -360,7 +360,6 @@ public function testEditedObjectRendersItsNewContentInTheSameProcess() $obj->content = '

Edited.

'; $obj->save(); - touch($filePath, $obj->mtime + 10); clearstatcache(); CmsObject::clearInternalCache(); diff --git a/modules/cms/twig/Loader.php b/modules/cms/twig/Loader.php index ca20215d26..136497b12c 100644 --- a/modules/cms/twig/Loader.php +++ b/modules/cms/twig/Loader.php @@ -74,7 +74,7 @@ public function getCacheKey(string $name): string return parent::getCacheKey($name); } - return $this->obj->getTwigCacheKey(); + return $this->obj->getTwigCacheKey() . '?' . hash('xxh128', (string) $this->obj->getTwigContent()); } /** From 362962ca228c4255de407f508536f958529a3b00 Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Thu, 15 Oct 2026 23:26:47 -0500 Subject: [PATCH 5/7] Explain each step of the Twig cache key test --- modules/cms/tests/classes/CmsObjectTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/modules/cms/tests/classes/CmsObjectTest.php b/modules/cms/tests/classes/CmsObjectTest.php index cb760bf206..73bda9043c 100644 --- a/modules/cms/tests/classes/CmsObjectTest.php +++ b/modules/cms/tests/classes/CmsObjectTest.php @@ -348,20 +348,36 @@ public function testEditedObjectRendersItsNewContentInTheSameProcess() file_put_contents($filePath, '

Original.

'); try { + /* + * One Twig environment for the whole test, configured the way Cms\ServiceProvider configures it. + * A long-running process such as a queue worker or an Octane worker keeps the environment, and + * every compiled template class it has loaded, between requests. + */ $loader = new TwigLoader(); $twig = new TwigEnvironment($loader, [ 'auto_reload' => true, 'cache' => new TwigCacheFilesystem($cachePath), ]); + /* + * First request: compile and render the original content. + */ $obj = TestTemporaryCmsObject::load($theme, 'twig-cache-key.htm'); $loader->setObject($obj); $this->assertSame('

Original.

', $twig->load($obj->getFilePath())->render([])); + /* + * An editor saves new content. The save lands in the same second as the first render, so the + * file's modification time may not change. + */ $obj->content = '

Edited.

'; $obj->save(); clearstatcache(); + /* + * Next request: load the object from disk again and render it with the same environment. If the + * cache key were unchanged, Twig would reuse the class it already loaded and render the original. + */ CmsObject::clearInternalCache(); $obj = TestTemporaryCmsObject::load($theme, 'twig-cache-key.htm'); $loader->setObject($obj); From 2ee1eddc4920597ae6293d42cea7042307e7c78b Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Fri, 30 Oct 2026 08:35:44 -0500 Subject: [PATCH 6/7] Add the content hash in getTwigCacheKey so the event can override it --- modules/cms/classes/CmsObject.php | 2 +- modules/cms/classes/ComponentPartial.php | 2 +- modules/cms/tests/classes/CmsObjectTest.php | 14 ++++++++++++++ modules/cms/twig/Loader.php | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/modules/cms/classes/CmsObject.php b/modules/cms/classes/CmsObject.php index 4411d61d94..bee81544e8 100644 --- a/modules/cms/classes/CmsObject.php +++ b/modules/cms/classes/CmsObject.php @@ -306,7 +306,7 @@ public function getTwigContent() */ public function getTwigCacheKey() { - $key = $this->getFilePath(); + $key = $this->getFilePath() . '?' . hash('xxh128', (string) $this->getTwigContent()); if ($event = $this->fireEvent('cmsObject.getTwigCacheKey', compact('key'), true)) { $key = $event; diff --git a/modules/cms/classes/ComponentPartial.php b/modules/cms/classes/ComponentPartial.php index 3df5200011..27444ef893 100644 --- a/modules/cms/classes/ComponentPartial.php +++ b/modules/cms/classes/ComponentPartial.php @@ -197,7 +197,7 @@ public function getTwigContent() */ public function getTwigCacheKey() { - return $this->getFilePath(); + return $this->getFilePath() . '?' . hash('xxh128', (string) $this->getTwigContent()); } /** diff --git a/modules/cms/tests/classes/CmsObjectTest.php b/modules/cms/tests/classes/CmsObjectTest.php index 73bda9043c..dc570ba783 100644 --- a/modules/cms/tests/classes/CmsObjectTest.php +++ b/modules/cms/tests/classes/CmsObjectTest.php @@ -387,4 +387,18 @@ public function testEditedObjectRendersItsNewContentInTheSameProcess() File::deleteDirectory($cachePath); } } + + public function testTwigCacheKeyCanBeOverriddenByEvent() + { + $theme = Theme::load('test'); + $obj = TestCmsObject::load($theme, 'plain.html'); + + $this->assertStringStartsWith($obj->getFilePath() . '?', $obj->getTwigCacheKey()); + + $obj->bindEvent('cmsObject.getTwigCacheKey', function ($key) { + return 'custom-key'; + }); + + $this->assertSame('custom-key', $obj->getTwigCacheKey()); + } } diff --git a/modules/cms/twig/Loader.php b/modules/cms/twig/Loader.php index 136497b12c..ca20215d26 100644 --- a/modules/cms/twig/Loader.php +++ b/modules/cms/twig/Loader.php @@ -74,7 +74,7 @@ public function getCacheKey(string $name): string return parent::getCacheKey($name); } - return $this->obj->getTwigCacheKey() . '?' . hash('xxh128', (string) $this->obj->getTwigContent()); + return $this->obj->getTwigCacheKey(); } /** From 274f0f2279ff129f83e05d4b5611b21e024441e9 Mon Sep 17 00:00:00 2001 From: Derrick Austin Date: Fri, 30 Oct 2026 08:52:34 -0500 Subject: [PATCH 7/7] Explain why the Twig cache key includes a content hash --- modules/cms/classes/CmsObject.php | 5 +++++ modules/cms/classes/ComponentPartial.php | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/modules/cms/classes/CmsObject.php b/modules/cms/classes/CmsObject.php index bee81544e8..6b2bcce35e 100644 --- a/modules/cms/classes/CmsObject.php +++ b/modules/cms/classes/CmsObject.php @@ -306,6 +306,11 @@ public function getTwigContent() */ public function getTwigCacheKey() { + /* + * Twig names the compiled template class after this key and skips its freshness check once that class is + * loaded, so a long-running process (such as an Octane worker) would keep rendering the old version after + * an edit. Including a hash of the content gives each version its own class. + */ $key = $this->getFilePath() . '?' . hash('xxh128', (string) $this->getTwigContent()); if ($event = $this->fireEvent('cmsObject.getTwigCacheKey', compact('key'), true)) { diff --git a/modules/cms/classes/ComponentPartial.php b/modules/cms/classes/ComponentPartial.php index 27444ef893..aedb799260 100644 --- a/modules/cms/classes/ComponentPartial.php +++ b/modules/cms/classes/ComponentPartial.php @@ -197,6 +197,11 @@ public function getTwigContent() */ public function getTwigCacheKey() { + /* + * Twig names the compiled template class after this key and skips its freshness check once that class is + * loaded, so a long-running process (such as an Octane worker) would keep rendering the old version after + * an edit. Including a hash of the content gives each version its own class. + */ return $this->getFilePath() . '?' . hash('xxh128', (string) $this->getTwigContent()); }