Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<id>` (link fieldtype) and
`statamic://entry::<id>` (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.
17 changes: 17 additions & 0 deletions config/cache_invalidation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/ContentDependencyInvalidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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];
Expand All @@ -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());
}
Expand Down
30 changes: 24 additions & 6 deletions src/EntryReferenceExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
Expand All @@ -55,4 +51,26 @@ private function extractRecursive(mixed $value): array

return [];
}

/**
* A bare id covers the entries fieldtype. The link fieldtype stores
* "entry::<id>" and Bard stores hrefs as "statamic://entry::<id>", so a rule
* pointed at either of those fields would otherwise never match.
*
* @return array<int, string>
*/
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] : [];
}
}
34 changes: 34 additions & 0 deletions src/HandleCollectionTreeSaved.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace RoxDigital\CacheInvalidation;

use Statamic\Events\CollectionTreeSaved;

/**
* A tree save changes entry URLs (a move) or their order (a reorder). The block
* index is keyed on absoluteUrl(), so after a move it maps URLs that no longer
* exist while the new ones are absent — every later invalidation then targets
* dead keys. Statamic's own CollectionTreeEntriesMovedOrRemoved purges the moved
* URLs straight through the cacher, bypassing the invalidator, so nothing else
* clears the index; and a pure reorder does not dispatch that event at all.
*/
class HandleCollectionTreeSaved
{
public function __construct(
private readonly PagebuilderDependencyScanner $pagebuilder,
private readonly StaticCacheFlusher $cache,
) {}

public function handle(CollectionTreeSaved $event): void
{
$this->pagebuilder->clearIndex();

$handle = $event->tree->collection()->handle();

if (in_array($handle, config('cache_invalidation.collection_trees_flush_all', []), true)) {
$this->cache->flush();
}
}
}
21 changes: 20 additions & 1 deletion src/PagebuilderDependencyScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, list<array<string, mixed>>>
*/
private function getIndex(): array
{
return Cache::rememberForever(self::INDEX_KEY, fn (): array => $this->buildIndex());
return Cache::rememberForever($this->indexKey(), fn (): array => $this->buildIndex());
}

/**
Expand Down
26 changes: 21 additions & 5 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace RoxDigital\CacheInvalidation;

use Statamic\Events\BlueprintSaved;
use Statamic\Events\CollectionTreeSaved;
use Statamic\Providers\AddonServiceProvider;

class ServiceProvider extends AddonServiceProvider
Expand All @@ -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
Expand Down
Loading