From 64ffb413b3e5309d8e92bff8f0e3b2dfde374274 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 21:32:22 -0700 Subject: [PATCH 1/8] Test sibling URIs are built once when validating a tree --- .../Collections/UpdateCollectionTreeTest.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/Feature/Collections/UpdateCollectionTreeTest.php b/tests/Feature/Collections/UpdateCollectionTreeTest.php index 60d4417e386..38cda0b3d8a 100644 --- a/tests/Feature/Collections/UpdateCollectionTreeTest.php +++ b/tests/Feature/Collections/UpdateCollectionTreeTest.php @@ -5,9 +5,12 @@ use Facades\Tests\Factories\EntryFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Contracts\Routing\UrlBuilder as UrlBuilderContract; use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\User; +use Statamic\Routing\UrlBuilder; +use Statamic\Structures\Page; use Tests\FakesRoles; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -126,6 +129,70 @@ private function duplicateUriTest($expectsRoot) ], $collection->structure()->in('en')->tree()); } + #[Test] + public function it_builds_each_siblings_uri_once_when_validating_moved_pages() + { + $user = tap(User::make()->makeSuper())->save(); + $collection = tap(Collection::make('test')->routes('{parent_uri}/{slug}'))->save(); + EntryFactory::id('e1')->collection($collection)->slug('alfa')->create(); + EntryFactory::id('e2')->collection($collection)->slug('bravo')->create(); + EntryFactory::id('e3')->collection($collection)->slug('charlie')->create(); + EntryFactory::id('e4')->collection($collection)->slug('delta')->create(); + EntryFactory::id('e5')->collection($collection)->slug('echo')->create(); + EntryFactory::id('e6')->collection($collection)->slug('foxtrot')->create(); + $collection->structureContents(['root' => false])->save(); + $collection->structure()->in('en')->tree([ + ['entry' => 'e1', 'children' => [ + ['entry' => 'e2', 'children' => [ + ['entry' => 'e3'], + ['entry' => 'e4'], + ['entry' => 'e5'], + ['entry' => 'e6'], + ]], + ]], + ])->save(); + + // Saving leaves the tree's pages cached in memory, where a request would load it fresh. + $collection->structure()->in('en')->flushCache(); + + $built = collect(); + + $this->app->bind(UrlBuilderContract::class, fn () => new class($built) extends UrlBuilder + { + public function __construct(private $built) + { + } + + public function build($route) + { + if ($this->content instanceof Page) { + $this->built->push($this->content->reference()); + } + + return parent::build($route); + } + }); + + $this + ->actingAs($user) + ->update($collection, ['pages' => [ + ['id' => 'e1', 'children' => [ + ['id' => 'e2', 'children' => [ + ['id' => 'e6', 'children' => []], + ['id' => 'e3', 'children' => []], + ['id' => 'e4', 'children' => []], + ['id' => 'e5', 'children' => []], + ]], + ]], + ]]) + ->assertOk(); + + $this->assertEquals( + ['e3' => 1, 'e4' => 1, 'e5' => 1, 'e6' => 1], + $built->countBy()->only(['e3', 'e4', 'e5', 'e6'])->sortKeys()->all(), + ); + } + #[Test] public function it_deletes_entries_scheduled_for_deletion() { From 63a3ca953fbf92d2969ab3d3e7ca8f6147ee27f0 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 21:32:23 -0700 Subject: [PATCH 2/8] Build sibling URIs once per parent when validating a tree --- .../CP/Collections/CollectionTreeController.php | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Http/Controllers/CP/Collections/CollectionTreeController.php b/src/Http/Controllers/CP/Collections/CollectionTreeController.php index 8bec1e94bc8..987e054efca 100644 --- a/src/Http/Controllers/CP/Collections/CollectionTreeController.php +++ b/src/Http/Controllers/CP/Collections/CollectionTreeController.php @@ -76,21 +76,20 @@ private function validateUniqueUris($tree) return; } + $siblingUris = []; + foreach ($tree->diff()->moved() as $id) { $page = $tree->find($id); $parent = $page->parent(); + $isTopLevel = ! $parent || $parent->isRoot(); + $key = $isTopLevel ? '' : $parent->id(); - $siblings = (! $parent || $parent->isRoot()) - ? $tree->pages()->all()->slice(1) - : $page->parent()->pages()->all(); - - $siblings = $siblings->reject(function ($sibling) use ($id) { - return $sibling->reference() === $id; - }); + $siblingUris[$key] ??= ($isTopLevel ? $tree->pages()->all()->slice(1) : $parent->pages()->all()) + ->mapWithKeys(fn ($sibling) => [$sibling->reference() => $sibling->uri()]); - $uris = $siblings->map->uri(); + $uri = $siblingUris[$key][$id] ?? $page->uri(); - if ($uris->contains($uri = $page->uri())) { + if ($siblingUris[$key]->except($id)->contains($uri)) { throw ValidationException::withMessages(['uri' => trans('statamic::validation.duplicate_uri', ['value' => $uri])]); } } From a1e2abec20a645d68d10ece2e1193d2315e32202 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 21:42:33 -0700 Subject: [PATCH 3/8] Test ancestor URIs are built once when validating a tree --- tests/Feature/Collections/UpdateCollectionTreeTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Collections/UpdateCollectionTreeTest.php b/tests/Feature/Collections/UpdateCollectionTreeTest.php index 38cda0b3d8a..7bf530c1b9a 100644 --- a/tests/Feature/Collections/UpdateCollectionTreeTest.php +++ b/tests/Feature/Collections/UpdateCollectionTreeTest.php @@ -188,8 +188,8 @@ public function build($route) ->assertOk(); $this->assertEquals( - ['e3' => 1, 'e4' => 1, 'e5' => 1, 'e6' => 1], - $built->countBy()->only(['e3', 'e4', 'e5', 'e6'])->sortKeys()->all(), + ['e1' => 1, 'e2' => 1, 'e3' => 1, 'e4' => 1, 'e5' => 1, 'e6' => 1], + $built->countBy()->sortKeys()->all(), ); } From 1b963d24f01082af0087abd6f62d8aba4b28b816 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 21:42:33 -0700 Subject: [PATCH 4/8] Remember a page's URI while the URI cache is off --- src/Structures/Page.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Structures/Page.php b/src/Structures/Page.php index d434f84caa4..4a79230b89f 100644 --- a/src/Structures/Page.php +++ b/src/Structures/Page.php @@ -49,6 +49,7 @@ class Page implements Arrayable, ArrayAccess, Augmentable, BulkAugmentable, Cont private $routeData; private $status; private $structure; + private $uri; public function __construct() { @@ -170,6 +171,7 @@ public function setEntry($reference): self $this->reference = $reference; $this->routeData = null; + $this->uri = null; return $this; } @@ -247,14 +249,18 @@ public function uri() return $cached; } + if (! $this->tree->uriCacheEnabled() && ! is_null($this->uri)) { + return $this->uri; + } + if (! $this->structure() instanceof CollectionStructure) { - return $uris[$this->reference] = $this->entry()->uri(); + return $this->uri = $uris[$this->reference] = $this->entry()->uri(); } $parentUri = $this->parent && ! $this->parent->isRoot() ? $this->parent->uri() : ''; $parentUri = Str::replace(['.html', '.htm'], '', $parentUri); - return $uris[$this->reference] = app(UrlBuilder::class) + return $this->uri = $uris[$this->reference] = app(UrlBuilder::class) ->content($this) ->merge([ 'parent_uri' => $parentUri, From d731ef528b1af0d67c819a74a103c7d0bd46a9f0 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 21:58:58 -0700 Subject: [PATCH 5/8] Explain the URI reuse in tree validation --- .../Controllers/CP/Collections/CollectionTreeController.php | 2 ++ src/Structures/Page.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Http/Controllers/CP/Collections/CollectionTreeController.php b/src/Http/Controllers/CP/Collections/CollectionTreeController.php index 987e054efca..7e53c6ddcbf 100644 --- a/src/Http/Controllers/CP/Collections/CollectionTreeController.php +++ b/src/Http/Controllers/CP/Collections/CollectionTreeController.php @@ -84,9 +84,11 @@ private function validateUniqueUris($tree) $isTopLevel = ! $parent || $parent->isRoot(); $key = $isTopLevel ? '' : $parent->id(); + // Moved pages under the same parent share siblings, so build that list once. $siblingUris[$key] ??= ($isTopLevel ? $tree->pages()->all()->slice(1) : $parent->pages()->all()) ->mapWithKeys(fn ($sibling) => [$sibling->reference() => $sibling->uri()]); + // The root is sliced off the top-level siblings, so it won't be in the list. $uri = $siblingUris[$key][$id] ?? $page->uri(); if ($siblingUris[$key]->except($id)->contains($uri)) { diff --git a/src/Structures/Page.php b/src/Structures/Page.php index 4a79230b89f..b92548e24fa 100644 --- a/src/Structures/Page.php +++ b/src/Structures/Page.php @@ -249,6 +249,8 @@ public function uri() return $cached; } + // With the shared cache off, a page still keeps its own URI, + // so siblings don't each rebuild their parent's. if (! $this->tree->uriCacheEnabled() && ! is_null($this->uri)) { return $this->uri; } From be815d023f5fa2d6b20e620bbd5cb273aa85db01 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 22:13:22 -0700 Subject: [PATCH 6/8] Test a page's URI follows its route and parent --- tests/Data/Structures/PageTest.php | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Data/Structures/PageTest.php b/tests/Data/Structures/PageTest.php index 63cd7d1b2ca..42811ab0031 100644 --- a/tests/Data/Structures/PageTest.php +++ b/tests/Data/Structures/PageTest.php @@ -188,6 +188,51 @@ public function slug($slug = null) $this->assertFalse($page->hasCustomUrl()); } + #[Test] + public function it_rebuilds_the_uri_when_its_route_or_parent_changes_while_the_uri_cache_is_disabled() + { + $entry = new class extends Entry + { + public function id($id = null) + { + return 'a'; + } + + public function slug($slug = null) + { + return 'entry-slug'; + } + }; + $collection = tap(\Statamic\Facades\Collection::make('test'))->save(); + $entry->collection('test'); + + $parent = Mockery::mock(Page::class); + $parent->shouldReceive('uri')->andReturn('/first/parent'); + $parent->shouldReceive('isRoot')->andReturnFalse(); + + $otherParent = Mockery::mock(Page::class); + $otherParent->shouldReceive('uri')->andReturn('/second/parent'); + $otherParent->shouldReceive('isRoot')->andReturnFalse(); + + $tree = $this->newTree()->disableUriCache()->setStructure( + $this->mock(CollectionStructure::class)->shouldReceive('collection')->andReturn($collection)->getMock() + ); + + $page = (new Page) + ->setTree($tree) + ->setRoute('{parent_uri}/{slug}') + ->setParent($parent) + ->setEntry($entry); + + $this->assertEquals('/first/parent/entry-slug', $page->uri()); + + $page->setRoute('/foo/{parent_uri}/{slug}'); + $this->assertEquals('/foo/first/parent/entry-slug', $page->uri()); + + $page->setParent($otherParent); + $this->assertEquals('/foo/second/parent/entry-slug', $page->uri()); + } + #[Test] #[DataProvider('stripExtensionFromParentUriProvider')] public function it_builds_a_uri_and_strips_out_file_extensions_from_parent_uri($ext) From 26a26b683ccb7d1ecabde2b9add62a93950c9b6c Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 22:13:39 -0700 Subject: [PATCH 7/8] Forget a page's URI when its position changes --- src/Structures/Page.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Structures/Page.php b/src/Structures/Page.php index b92548e24fa..8d21c5f4e82 100644 --- a/src/Structures/Page.php +++ b/src/Structures/Page.php @@ -121,6 +121,7 @@ public function isRedirect() public function setDepth($depth) { $this->depth = $depth; + $this->uri = null; return $this; } @@ -207,6 +208,7 @@ public function parent(): ?self public function setParent(?self $parent): self { $this->parent = $parent; + $this->uri = null; return $this; } @@ -214,6 +216,7 @@ public function setParent(?self $parent): self public function setRoute(?string $route): self { $this->route = $route; + $this->uri = null; return $this; } @@ -307,6 +310,7 @@ public function isRoot() public function setTree($tree) { $this->tree = $tree; + $this->uri = null; return $this; } @@ -314,6 +318,7 @@ public function setTree($tree) public function setRoot(bool $isRoot) { $this->isRoot = $isRoot; + $this->uri = null; return $this; } From 0ed3c379cbe2f53b2c4575fa9684254a346d9f40 Mon Sep 17 00:00:00 2001 From: edalzell Date: Fri, 13 Nov 2026 22:18:30 -0700 Subject: [PATCH 8/8] Trim the URI tests --- tests/Data/Structures/PageTest.php | 9 +------ .../Collections/UpdateCollectionTreeTest.php | 24 ++++--------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/tests/Data/Structures/PageTest.php b/tests/Data/Structures/PageTest.php index 42811ab0031..41df8b301f2 100644 --- a/tests/Data/Structures/PageTest.php +++ b/tests/Data/Structures/PageTest.php @@ -189,7 +189,7 @@ public function slug($slug = null) } #[Test] - public function it_rebuilds_the_uri_when_its_route_or_parent_changes_while_the_uri_cache_is_disabled() + public function it_rebuilds_the_uri_when_its_route_changes_while_the_uri_cache_is_disabled() { $entry = new class extends Entry { @@ -210,10 +210,6 @@ public function slug($slug = null) $parent->shouldReceive('uri')->andReturn('/first/parent'); $parent->shouldReceive('isRoot')->andReturnFalse(); - $otherParent = Mockery::mock(Page::class); - $otherParent->shouldReceive('uri')->andReturn('/second/parent'); - $otherParent->shouldReceive('isRoot')->andReturnFalse(); - $tree = $this->newTree()->disableUriCache()->setStructure( $this->mock(CollectionStructure::class)->shouldReceive('collection')->andReturn($collection)->getMock() ); @@ -228,9 +224,6 @@ public function slug($slug = null) $page->setRoute('/foo/{parent_uri}/{slug}'); $this->assertEquals('/foo/first/parent/entry-slug', $page->uri()); - - $page->setParent($otherParent); - $this->assertEquals('/foo/second/parent/entry-slug', $page->uri()); } #[Test] diff --git a/tests/Feature/Collections/UpdateCollectionTreeTest.php b/tests/Feature/Collections/UpdateCollectionTreeTest.php index 7bf530c1b9a..79c96de8c5f 100644 --- a/tests/Feature/Collections/UpdateCollectionTreeTest.php +++ b/tests/Feature/Collections/UpdateCollectionTreeTest.php @@ -137,18 +137,11 @@ public function it_builds_each_siblings_uri_once_when_validating_moved_pages() EntryFactory::id('e1')->collection($collection)->slug('alfa')->create(); EntryFactory::id('e2')->collection($collection)->slug('bravo')->create(); EntryFactory::id('e3')->collection($collection)->slug('charlie')->create(); - EntryFactory::id('e4')->collection($collection)->slug('delta')->create(); - EntryFactory::id('e5')->collection($collection)->slug('echo')->create(); - EntryFactory::id('e6')->collection($collection)->slug('foxtrot')->create(); $collection->structureContents(['root' => false])->save(); $collection->structure()->in('en')->tree([ ['entry' => 'e1', 'children' => [ - ['entry' => 'e2', 'children' => [ - ['entry' => 'e3'], - ['entry' => 'e4'], - ['entry' => 'e5'], - ['entry' => 'e6'], - ]], + ['entry' => 'e2'], + ['entry' => 'e3'], ]], ])->save(); @@ -177,20 +170,13 @@ public function build($route) ->actingAs($user) ->update($collection, ['pages' => [ ['id' => 'e1', 'children' => [ - ['id' => 'e2', 'children' => [ - ['id' => 'e6', 'children' => []], - ['id' => 'e3', 'children' => []], - ['id' => 'e4', 'children' => []], - ['id' => 'e5', 'children' => []], - ]], + ['id' => 'e3', 'children' => []], + ['id' => 'e2', 'children' => []], ]], ]]) ->assertOk(); - $this->assertEquals( - ['e1' => 1, 'e2' => 1, 'e3' => 1, 'e4' => 1, 'e5' => 1, 'e6' => 1], - $built->countBy()->sortKeys()->all(), - ); + $this->assertEquals(['e1' => 1, 'e2' => 1, 'e3' => 1], $built->countBy()->sortKeys()->all()); } #[Test]