Skip to content

Commit b4f659c

Browse files
ryanmitchellclaudejasonvarga
authored
[6.x] Ensure moved/removed entries are statically invalidated (#14386)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 474ba15 commit b4f659c

5 files changed

Lines changed: 252 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Statamic\Events;
4+
5+
use Statamic\Facades\Entry;
6+
7+
class CollectionTreeEntriesMovedOrRemoved extends Event
8+
{
9+
public array $removedUrls;
10+
public array $movedUrls;
11+
12+
public function __construct(
13+
public array $removed,
14+
public array $moved,
15+
) {
16+
$this->removedUrls = $this->resolveUrls($removed);
17+
$this->movedUrls = $this->resolveUrls($moved);
18+
}
19+
20+
private function resolveUrls(array $ids): array
21+
{
22+
return collect($ids)
23+
->flatMap(function ($id) {
24+
if (! $entry = Entry::find($id)) {
25+
return [];
26+
}
27+
28+
return $entry->descendants()
29+
->merge([$entry])
30+
->reject->isRedirect()
31+
->map->absoluteUrl()
32+
->filter()
33+
->values()
34+
->all();
35+
})
36+
->unique()
37+
->values()
38+
->all();
39+
}
40+
}

src/StaticCaching/Invalidate.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Statamic\Events\BlueprintDeleted;
99
use Statamic\Events\BlueprintSaved;
1010
use Statamic\Events\CollectionTreeDeleted;
11+
use Statamic\Events\CollectionTreeEntriesMovedOrRemoved;
1112
use Statamic\Events\CollectionTreeSaved;
1213
use Statamic\Events\EntryDeleting;
1314
use Statamic\Events\EntrySaved;
@@ -27,6 +28,7 @@
2728
class Invalidate implements ShouldQueue
2829
{
2930
protected $invalidator;
31+
protected $cacher;
3032

3133
protected $events = [
3234
AssetSaved::class => 'refreshAsset',
@@ -42,6 +44,7 @@ class Invalidate implements ShouldQueue
4244
NavDeleted::class => 'invalidateNav',
4345
FormSaved::class => 'refreshForm',
4446
FormDeleted::class => 'invalidateForm',
47+
CollectionTreeEntriesMovedOrRemoved::class => 'invalidateMovedOrRemovedEntries',
4548
CollectionTreeSaved::class => 'invalidateCollectionByTree',
4649
CollectionTreeDeleted::class => 'invalidateCollectionByTree',
4750
NavTreeSaved::class => 'refreshNavByTree',
@@ -50,9 +53,10 @@ class Invalidate implements ShouldQueue
5053
BlueprintDeleted::class => 'invalidateByBlueprint',
5154
];
5255

53-
public function __construct(Invalidator $invalidator)
56+
public function __construct(Invalidator $invalidator, Cacher $cacher)
5457
{
5558
$this->invalidator = $invalidator;
59+
$this->cacher = $cacher;
5660
}
5761

5862
public function subscribe($dispatcher)
@@ -122,6 +126,13 @@ public function refreshForm($event)
122126
$this->invalidator->refresh($event->form);
123127
}
124128

129+
public function invalidateMovedOrRemovedEntries($event)
130+
{
131+
if ($urls = array_merge($event->removedUrls, $event->movedUrls)) {
132+
$this->cacher->invalidateUrls($urls);
133+
}
134+
}
135+
125136
public function invalidateCollectionByTree($event)
126137
{
127138
$this->invalidator->invalidate($event->tree);

src/Structures/CollectionTree.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Statamic\Contracts\Structures\CollectionTree as TreeContract;
77
use Statamic\Contracts\Structures\CollectionTreeRepository;
88
use Statamic\Events\CollectionTreeDeleted;
9+
use Statamic\Events\CollectionTreeEntriesMovedOrRemoved;
910
use Statamic\Events\CollectionTreeSaved;
1011
use Statamic\Events\CollectionTreeSaving;
1112
use Statamic\Facades\Blink;
@@ -48,7 +49,17 @@ protected function dispatchSavedEvent()
4849

4950
protected function dispatchSavingEvent()
5051
{
51-
return CollectionTreeSaving::dispatch($this);
52+
if (CollectionTreeSaving::dispatch($this) === false) {
53+
return false;
54+
}
55+
56+
$diff = $this->diff();
57+
$removed = $diff->removed();
58+
$moved = $diff->ancestryChanged();
59+
60+
if ($removed || $moved) {
61+
CollectionTreeEntriesMovedOrRemoved::dispatch($removed, $moved);
62+
}
5263
}
5364

5465
protected function dispatchDeletedEvent()

tests/Data/Structures/CollectionTreeTest.php

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use Illuminate\Support\Facades\Event;
66
use PHPUnit\Framework\Attributes\Test;
7+
use Statamic\Events\CollectionTreeEntriesMovedOrRemoved;
78
use Statamic\Events\CollectionTreeSaving;
89
use Statamic\Facades\Blink;
910
use Statamic\Facades\Collection;
11+
use Statamic\Facades\Entry;
1012
use Statamic\Structures\CollectionTree;
1113
use Statamic\Structures\CollectionTreeDiff;
1214
use Tests\PreventSavingStacheItemsToDisk;
@@ -142,4 +144,112 @@ public function returning_false_in_collection_tree_saving_stops_saving()
142144

143145
$this->assertFileDoesNotExist($tree->path());
144146
}
147+
148+
#[Test]
149+
public function it_fires_entries_moved_or_removed_event_when_entries_are_removed()
150+
{
151+
Event::fake();
152+
Entry::shouldReceive('find')->andReturn(null);
153+
154+
$collection = Collection::make('test')->structureContents(['root' => true]);
155+
Collection::shouldReceive('findByHandle')->with('test')->andReturn($collection);
156+
157+
$tree = $collection->structure()->makeTree('en', [
158+
['entry' => '1.0'],
159+
['entry' => '2.0'],
160+
]);
161+
162+
$tree->tree([
163+
['entry' => '1.0'],
164+
]);
165+
166+
$tree->save();
167+
168+
Event::assertDispatched(CollectionTreeEntriesMovedOrRemoved::class, function ($event) {
169+
return $event->removed === ['2.0'] && $event->moved === [];
170+
});
171+
}
172+
173+
#[Test]
174+
public function it_fires_entries_moved_or_removed_event_when_entries_change_ancestry()
175+
{
176+
Event::fake();
177+
Entry::shouldReceive('find')->andReturn(null);
178+
179+
$collection = Collection::make('test')->structureContents(['root' => true]);
180+
Collection::shouldReceive('findByHandle')->with('test')->andReturn($collection);
181+
182+
$tree = $collection->structure()->makeTree('en', [
183+
['entry' => '1.0', 'children' => [
184+
['entry' => '1.1'],
185+
]],
186+
['entry' => '2.0'],
187+
]);
188+
189+
$tree->tree([
190+
['entry' => '1.0'],
191+
['entry' => '2.0', 'children' => [
192+
['entry' => '1.1'],
193+
]],
194+
]);
195+
196+
$tree->save();
197+
198+
Event::assertDispatched(CollectionTreeEntriesMovedOrRemoved::class, function ($event) {
199+
return $event->removed === [] && $event->moved === ['1.1'];
200+
});
201+
}
202+
203+
#[Test]
204+
public function it_does_not_fire_entries_moved_or_removed_event_when_entries_are_only_reordered()
205+
{
206+
Event::fake();
207+
208+
$collection = Collection::make('test')->structureContents(['root' => true]);
209+
Collection::shouldReceive('findByHandle')->with('test')->andReturn($collection);
210+
211+
$tree = $collection->structure()->makeTree('en', [
212+
['entry' => '1.0', 'children' => [
213+
['entry' => '1.1'],
214+
['entry' => '1.2'],
215+
]],
216+
]);
217+
218+
$tree->tree([
219+
['entry' => '1.0', 'children' => [
220+
['entry' => '1.2'],
221+
['entry' => '1.1'],
222+
]],
223+
]);
224+
225+
$tree->save();
226+
227+
Event::assertNotDispatched(CollectionTreeEntriesMovedOrRemoved::class);
228+
}
229+
230+
#[Test]
231+
public function it_does_not_fire_entries_moved_or_removed_event_when_saving_is_halted()
232+
{
233+
Event::fake([CollectionTreeEntriesMovedOrRemoved::class]);
234+
235+
Event::listen(CollectionTreeSaving::class, function () {
236+
return false;
237+
});
238+
239+
$collection = Collection::make('test')->structureContents(['root' => true]);
240+
Collection::shouldReceive('findByHandle')->with('test')->andReturn($collection);
241+
242+
$tree = $collection->structure()->makeTree('en', [
243+
['entry' => '1.0'],
244+
['entry' => '2.0'],
245+
]);
246+
247+
$tree->tree([
248+
['entry' => '1.0'],
249+
]);
250+
251+
$tree->save();
252+
253+
Event::assertNotDispatched(CollectionTreeEntriesMovedOrRemoved::class);
254+
}
145255
}

tests/StaticCaching/InvalidateTest.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44

55
use Mockery;
66
use PHPUnit\Framework\Attributes\Test;
7+
use Statamic\Contracts\Entries\Entry;
78
use Statamic\Events\BlueprintSaved;
9+
use Statamic\Events\CollectionTreeEntriesMovedOrRemoved;
10+
use Statamic\Facades\Entry as EntryFacade;
811
use Statamic\Facades\Form;
12+
use Statamic\StaticCaching\Cacher;
913
use Statamic\StaticCaching\Invalidate;
1014
use Statamic\StaticCaching\Invalidator;
1115
use Tests\PreventSavingStacheItemsToDisk;
@@ -26,8 +30,81 @@ public function it_invalidates_a_form_when_its_blueprint_is_saved()
2630
return $form->handle() === 'contact';
2731
})->getMock();
2832

29-
$invalidate = new Invalidate($invalidator);
33+
$invalidate = new Invalidate($invalidator, Mockery::mock(Cacher::class));
3034

3135
$invalidate->invalidateByBlueprint($event);
3236
}
37+
38+
private function mockEntry(string $url): Entry
39+
{
40+
$entry = Mockery::mock(Entry::class);
41+
$entry->shouldReceive('descendants')->andReturn(collect());
42+
$entry->shouldReceive('isRedirect')->andReturn(false);
43+
$entry->shouldReceive('absoluteUrl')->andReturn($url);
44+
45+
return $entry;
46+
}
47+
48+
#[Test]
49+
public function it_invalidates_removed_entries_when_collection_tree_is_saving()
50+
{
51+
EntryFacade::shouldReceive('find')->with('entry-1')->andReturn($this->mockEntry('http://example.com/entry-1'));
52+
EntryFacade::shouldReceive('find')->with('entry-2')->andReturn($this->mockEntry('http://example.com/entry-2'));
53+
54+
$event = new CollectionTreeEntriesMovedOrRemoved(removed: ['entry-1', 'entry-2'], moved: []);
55+
56+
$cacher = Mockery::mock(Cacher::class);
57+
$cacher->shouldReceive('invalidateUrls')
58+
->with(['http://example.com/entry-1', 'http://example.com/entry-2'])
59+
->once();
60+
61+
$invalidate = new Invalidate(Mockery::mock(Invalidator::class), $cacher);
62+
63+
$invalidate->invalidateMovedOrRemovedEntries($event);
64+
}
65+
66+
#[Test]
67+
public function it_invalidates_entries_with_changed_ancestry_when_collection_tree_is_saving()
68+
{
69+
EntryFacade::shouldReceive('find')->with('entry-1')->andReturn($this->mockEntry('http://example.com/entry-1'));
70+
71+
$event = new CollectionTreeEntriesMovedOrRemoved(removed: [], moved: ['entry-1']);
72+
73+
$cacher = Mockery::mock(Cacher::class);
74+
$cacher->shouldReceive('invalidateUrls')
75+
->with(['http://example.com/entry-1'])
76+
->once();
77+
78+
$invalidate = new Invalidate(Mockery::mock(Invalidator::class), $cacher);
79+
80+
$invalidate->invalidateMovedOrRemovedEntries($event);
81+
}
82+
83+
#[Test]
84+
public function it_does_not_invalidate_entries_only_reordered_within_same_parent_when_collection_tree_is_saving()
85+
{
86+
$event = new CollectionTreeEntriesMovedOrRemoved(removed: [], moved: []);
87+
88+
$cacher = Mockery::mock(Cacher::class);
89+
$cacher->shouldNotReceive('invalidateUrls');
90+
91+
$invalidate = new Invalidate(Mockery::mock(Invalidator::class), $cacher);
92+
93+
$invalidate->invalidateMovedOrRemovedEntries($event);
94+
}
95+
96+
#[Test]
97+
public function it_skips_entries_that_cannot_be_found_when_collection_tree_is_saving()
98+
{
99+
EntryFacade::shouldReceive('find')->with('missing-entry')->andReturn(null);
100+
101+
$event = new CollectionTreeEntriesMovedOrRemoved(removed: ['missing-entry'], moved: []);
102+
103+
$cacher = Mockery::mock(Cacher::class);
104+
$cacher->shouldNotReceive('invalidateUrls');
105+
106+
$invalidate = new Invalidate(Mockery::mock(Invalidator::class), $cacher);
107+
108+
$invalidate->invalidateMovedOrRemovedEntries($event);
109+
}
33110
}

0 commit comments

Comments
 (0)