From ab1f43021f0a85c8df5df8e6a2e5e383dcf9897f Mon Sep 17 00:00:00 2001 From: Bob Vrijland Date: Wed, 29 Jul 2026 14:54:06 +0200 Subject: [PATCH] fix: close static-cache invalidation gaps - fingerprint the block index key with the rules config - always apply customEntryUrls() - resolve link/Bard entry references - clear the index on collection tree saves - bind $rules for subclassed invalidators Adds collection_trees_flush_all (defaults to empty). --- CHANGELOG.md | 35 ++++++++++++++++++++++++++++ config/cache_invalidation.php | 17 ++++++++++++++ src/ContentDependencyInvalidator.php | 18 +++++++++----- src/EntryReferenceExtractor.php | 30 +++++++++++++++++++----- src/HandleCollectionTreeSaved.php | 34 +++++++++++++++++++++++++++ src/PagebuilderDependencyScanner.php | 21 ++++++++++++++++- src/ServiceProvider.php | 26 +++++++++++++++++---- 7 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 src/HandleCollectionTreeSaved.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc0c7b..ea59007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,3 +40,38 @@ All notable changes to this project will be documented in this file. - Support for block-type targeting, field-level entry reference matching, and explicit URL lists. - `customEntryUrls()` extension point for site-specific subclasses. - Slim index storage — only `type` and rule-referenced fields are persisted, discarding rich-text and other large values. + +## v1.1.0 + +### Fixed + +- The block index cache key is now fingerprinted with `collection_entry_rules` and + `pagebuilder_collections`. The index only stores the block fields named in those + rules, so an index built under an older rule set was missing the keys new rules + match on — adding a field-scoped rule silently matched nothing until something + else happened to clear the index. +- `customEntryUrls()` now always applies. It previously ran only for collections + with no rules, which made the documented extension point unreachable for exactly + the collections that need it: having block rules does not mean those rules cover + every way a collection's entries surface. +- `EntryReferenceExtractor` now resolves `entry::` (link fieldtype) and + `statamic://entry::` (Bard hrefs), and no longer stops at a UUID-shaped `id` + key instead of descending into the rest of the array. +- Saving a collection tree clears the block index. The index is keyed on + `absoluteUrl()`, and Statamic purges moved URLs straight through the cacher + without going via the invalidator, so nothing cleared the index after a move; a + pure reorder dispatches no move event at all. +- The `$rules` contextual binding is now also registered for the class named in + `statamic.static_caching.invalidation.class`. Contextual bindings are keyed on + the exact concrete, so pointing the config at a subclass previously threw + `BindingResolutionException` on the first save unless the host app duplicated + the binding. + +### Added + +- `collection_trees_flush_all` — collections whose tree order drives shared output + (a nav or breadcrumbs built from the page tree) and so must flush the whole cache + on reorder. Empty by default. + +All changes are backwards compatible: no constructor signatures changed, the new +config key defaults to empty, and reference extraction only ever matches more. diff --git a/config/cache_invalidation.php b/config/cache_invalidation.php index a99cca9..215e71a 100644 --- a/config/cache_invalidation.php +++ b/config/cache_invalidation.php @@ -42,6 +42,23 @@ 'navigation', ], + /* + |-------------------------------------------------------------------------- + | Collection trees that flush the entire static cache + |-------------------------------------------------------------------------- + | + | Saving a collection tree always clears the block index, because a move + | changes entry URLs and the index is keyed on them. List a collection here + | as well when its tree drives shared output — a nav or breadcrumbs built + | from the page tree, say — since reordering it changes every cached page + | and no block rule can express that. Empty by default. + | + */ + + 'collection_trees_flush_all' => [ + // + ], + /* |-------------------------------------------------------------------------- | Flush the entire static cache when a form blueprint is saved diff --git a/src/ContentDependencyInvalidator.php b/src/ContentDependencyInvalidator.php index 860c8c5..e2045e6 100644 --- a/src/ContentDependencyInvalidator.php +++ b/src/ContentDependencyInvalidator.php @@ -99,7 +99,7 @@ private function shouldFlushAll(mixed $item): bool return false; } - private function urlsFor(mixed $item): Collection + protected function urlsFor(mixed $item): Collection { if ($item instanceof Variables) { return $this->urlsForGlobal($item); @@ -116,20 +116,26 @@ private function urlsFor(mixed $item): Collection return collect(); } - private function urlsForGlobal(Variables $variables): Collection + protected function urlsForGlobal(Variables $variables): Collection { return $this->urlsWithBlockTargets('global_urls', 'global_target_blocks', $variables->globalSet()->handle()); } - private function urlsForEntry(Entry $entry): Collection + protected function urlsForEntry(Entry $entry): Collection { $collection = $entry->collectionHandle(); $rules = config('cache_invalidation.collection_entry_rules', []); + + // Always applied: a collection having block rules does not mean those + // rules cover every way its entries surface. Anything rendered by a + // collection's own template, rather than by a pagebuilder block, can + // only be expressed here. $urls = $this->ownUrl($entry) - ->merge($this->configuredUrls('collection_urls', $collection)); + ->merge($this->configuredUrls('collection_urls', $collection)) + ->merge($this->customEntryUrls($entry)); if (! array_key_exists($collection, $rules)) { - return $urls->merge($this->customEntryUrls($entry)); + return $urls; } $rule = $rules[$collection]; @@ -147,7 +153,7 @@ private function urlsForEntry(Entry $entry): Collection ); } - private function urlsForTaxonomyTerm(LocalizedTerm $term): Collection + protected function urlsForTaxonomyTerm(LocalizedTerm $term): Collection { return $this->urlsWithBlockTargets('taxonomy_urls', 'taxonomy_target_blocks', $term->taxonomyHandle()); } diff --git a/src/EntryReferenceExtractor.php b/src/EntryReferenceExtractor.php index ecefb81..59cce42 100644 --- a/src/EntryReferenceExtractor.php +++ b/src/EntryReferenceExtractor.php @@ -27,8 +27,8 @@ public function extract(mixed $value): array */ private function extractRecursive(mixed $value): array { - if (is_string($value) && Str::isUuid($value)) { - return [$value]; + if (is_string($value)) { + return $this->extractFromString($value); } if ($value instanceof Entry) { @@ -42,10 +42,6 @@ private function extractRecursive(mixed $value): array ->all(); } - if (is_array($value) && isset($value['id']) && is_string($value['id']) && Str::isUuid($value['id'])) { - return [$value['id']]; - } - if (is_array($value)) { return collect($value) ->flatMap(fn (mixed $item): array => $this->extractRecursive($item)) @@ -55,4 +51,26 @@ private function extractRecursive(mixed $value): array return []; } + + /** + * A bare id covers the entries fieldtype. The link fieldtype stores + * "entry::" and Bard stores hrefs as "statamic://entry::", so a rule + * pointed at either of those fields would otherwise never match. + * + * @return array + */ + private function extractFromString(string $value): array + { + if (Str::isUuid($value)) { + return [$value]; + } + + if (! Str::contains($value, 'entry::')) { + return []; + } + + $id = Str::after($value, 'entry::'); + + return Str::isUuid($id) ? [$id] : []; + } } diff --git a/src/HandleCollectionTreeSaved.php b/src/HandleCollectionTreeSaved.php new file mode 100644 index 0000000..40a1fcc --- /dev/null +++ b/src/HandleCollectionTreeSaved.php @@ -0,0 +1,34 @@ +pagebuilder->clearIndex(); + + $handle = $event->tree->collection()->handle(); + + if (in_array($handle, config('cache_invalidation.collection_trees_flush_all', []), true)) { + $this->cache->flush(); + } + } +} diff --git a/src/PagebuilderDependencyScanner.php b/src/PagebuilderDependencyScanner.php index b6eee8b..61fcc40 100644 --- a/src/PagebuilderDependencyScanner.php +++ b/src/PagebuilderDependencyScanner.php @@ -28,15 +28,34 @@ public function urlsForBlocksMatching(Closure $predicate): Collection public function clearIndex(): void { + Cache::forget($this->indexKey()); Cache::forget(self::INDEX_KEY); } + /** + * The index only keeps the block fields named in collection_entry_rules + * (see slimBlocks), so a cached index built under a different rule set is + * not just stale, it is missing keys the new rules match on. Fingerprinting + * the config into the key means such an index is never read: without this, + * adding a field-scoped rule silently matches nothing until something else + * happens to clear the index. + */ + private function indexKey(): string + { + $fingerprint = md5(serialize([ + config('cache_invalidation.collection_entry_rules', []), + config('cache_invalidation.pagebuilder_collections', ['pages']), + ])); + + return self::INDEX_KEY.'.'.$fingerprint; + } + /** * @return array>> */ private function getIndex(): array { - return Cache::rememberForever(self::INDEX_KEY, fn (): array => $this->buildIndex()); + return Cache::rememberForever($this->indexKey(), fn (): array => $this->buildIndex()); } /** diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index e703753..9fc4371 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -5,6 +5,7 @@ namespace RoxDigital\CacheInvalidation; use Statamic\Events\BlueprintSaved; +use Statamic\Events\CollectionTreeSaved; use Statamic\Providers\AddonServiceProvider; class ServiceProvider extends AddonServiceProvider @@ -15,23 +16,38 @@ class ServiceProvider extends AddonServiceProvider BlueprintSaved::class => [ FlushStaticCacheOnFormBlueprintSaved::class, ], + CollectionTreeSaved::class => [ + HandleCollectionTreeSaved::class, + ], ]; public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/cache_invalidation.php', 'cache_invalidation'); - $this->app - ->when(ContentDependencyInvalidator::class) - ->needs('$rules') - ->giveConfig('statamic.static_caching.invalidation.rules'); - if ($this->app['config']->get('statamic.static_caching.invalidation.class') === null) { $this->app['config']->set( 'statamic.static_caching.invalidation.class', ContentDependencyInvalidator::class, ); } + + /* + * Contextual bindings are keyed on the exact concrete, so binding only + * this class leaves a host app that points the config at a subclass with + * an unresolvable $rules parameter. Bind the configured class too. + */ + $concretes = array_unique(array_filter([ + ContentDependencyInvalidator::class, + $this->app['config']->get('statamic.static_caching.invalidation.class'), + ])); + + foreach ($concretes as $concrete) { + $this->app + ->when($concrete) + ->needs('$rules') + ->giveConfig('statamic.static_caching.invalidation.rules'); + } } public function bootAddon(): void