From 0cb0a538813bb64b1b7eae0920ef0ba9a233f6ac Mon Sep 17 00:00:00 2001 From: Steve Parks Date: Wed, 26 Aug 2026 18:45:47 +0200 Subject: [PATCH 1/2] Refactor pagination URL handling in StaticWarm command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StaticWarm` has a different approach to pagination than `StaticWarmJob`, and introduces an issue as a result, re-paginating already-paginated URLs. `StaticWarmJob` guards against following pagination on a URL that is itself a page, and builds its URLs with the right separator: ```php private function shouldWarmPaginatedPages(ResponseInterface $response): bool { if (! $response->hasHeader('X-Statamic-Pagination')) { return false; } [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); return ! str_contains($this->request->getUri()->getQuery(), "{$pageName}="); } ``` ```php return implode('', [ $url, str_contains($url, '?') ? '&' : '?', "{$pageName}={$page}", ]); ``` `StaticWarm` has neither. It appends unconditionally: ```php $url = "{$url}?{$pageName}={$page}"; ``` so any URL reaching `outputSuccessLine()` that already carries `?page=N` produces `?page=3?page=4`, `?page=3?page=5`, and so on. This reaches real sites because `additionalUris()` is a public extension point (`StaticWarm::hook('additional', …)`), and feeding paginated URLs through it is a reasonable thing to do. Eg it is how a site re-warms `?page=N` pages that fell out of the cache while their index page stayed in it, which the compiled URI list cannot reach on its own because pagination is only discoverable from a response header. Once such a URL is in the main pass, it answers with a pagination header of its own and the double-query URLs follow. They render — so the pool reports them `✓ Cached` — but they are not the canonical URL of anything and never enter the cache. Observed on my production site: a tracked-pagination set growing 130 → 243 entries in fifty minutes, all of it junk, plus the wasted renders. The work grows with the square of the pagination set. ### The fix Mirror the job: a named `shouldWarmPaginatedPages()` predicate that folds in the `hasHeader` check, called from `outputSuccessLine()`. ```diff public function outputSuccessLine(Response $response, $index): void { - $this->components->twoColumnDetail($this->getRelativeUri($this->uris()->get($index)), '✓ Cached'); + $url = $this->uris()->get($index); + + $this->components->twoColumnDetail($this->getRelativeUri($url), '✓ Cached'); - if ($response->hasHeader('X-Statamic-Pagination')) { + if ($this->shouldWarmPaginatedPages($response, $url)) { [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); - $this->warmPaginatedPages($this->uris()->get($index), $currentPage, $totalPages, $pageName); + $this->warmPaginatedPages($url, $currentPage, $totalPages, $pageName); } } + private function shouldWarmPaginatedPages(Response $response, string $url): bool + { + if (! $response->hasHeader('X-Statamic-Pagination')) { + return false; + } + + [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); + + return ! str_contains(parse_url($url, PHP_URL_QUERY) ?? '', "{$pageName}="); + } ``` and the separator, which also fixes a quieter case — any warmable URL with a pre-existing query string currently gets a malformed second `?`: ```diff $urls = collect(range($currentPage, $totalPages))->map(function ($page) use ($url, $pageName) { - $url = "{$url}?{$pageName}={$page}"; + $url = $url.(str_contains($url, '?') ? '&' : '?')."{$pageName}={$page}"; ``` ### Alternative: put the predicate in the trait both classes already use `NormalizesPaginationHeader` is shared by `StaticWarm` and `StaticWarmJob` and is already the home for pagination-header logic, so the guard arguably belongs there rather than being written twice: ```php trait NormalizesPaginationHeader { protected function paginationHeader(ResponseInterface $response): array { /* unchanged */ } protected function shouldWarmPaginatedPages(ResponseInterface $response, string $query): bool { if (! $response->hasHeader('X-Statamic-Pagination')) { return false; } [, , $pageName] = $this->paginationHeader($response); return ! str_contains($query, "{$pageName}="); } } ``` Each caller passes its own query string — `parse_url($url, PHP_URL_QUERY) ?? ''` in the command, `$this->request->getUri()->getQuery()` in the job — which is the only thing that genuinely differs between them. This fixes both classes at once and leaves one place to change next time. --- src/Console/Commands/StaticWarm.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php index d8b3867aa26..aaa78c323bf 100644 --- a/src/Console/Commands/StaticWarm.php +++ b/src/Console/Commands/StaticWarm.php @@ -124,7 +124,7 @@ private function warm(): void private function warmPaginatedPages(string $url, int $currentPage, int $totalPages, string $pageName): void { $urls = collect(range($currentPage, $totalPages))->map(function ($page) use ($url, $pageName) { - $url = "{$url}?{$pageName}={$page}"; + $url = $url.(str_contains($url, '?') ? '&' : '?')."{$pageName}={$page}"; if (config('statamic.static_caching.background_recache', false)) { $url = RecacheToken::addToUrl($url); @@ -172,12 +172,14 @@ private function clientConfig(): array public function outputSuccessLine(Response $response, $index): void { - $this->components->twoColumnDetail($this->getRelativeUri($this->uris()->get($index)), '✓ Cached'); + $url = $this->uris()->get($index); - if ($response->hasHeader('X-Statamic-Pagination')) { + $this->components->twoColumnDetail($this->getRelativeUri($url), '✓ Cached'); + + if ($this->shouldWarmPaginatedPages($response, $url)) { [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); - $this->warmPaginatedPages($this->uris()->get($index), $currentPage, $totalPages, $pageName); + $this->warmPaginatedPages($url, $currentPage, $totalPages, $pageName); } } @@ -276,6 +278,17 @@ private function shouldExclude($uri): bool return collect($exclusions)->contains(fn ($excluded) => $this->uriMatches($uri, $excluded)); } + private function shouldWarmPaginatedPages(Response $response, string $url): bool + { + if (! $response->hasHeader('X-Statamic-Pagination')) { + return false; + } + + [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); + + return ! str_contains(parse_url($url, PHP_URL_QUERY) ?? '', "{$pageName}="); + } + private function uriMatches($uri, $pattern): bool { $uri = URL::makeRelative($uri); From f3b438dc005277ca6bdda04597fa8e9dc72ce732 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 26 Aug 2026 18:17:11 -0400 Subject: [PATCH 2/2] Update StaticWarm.php --- src/Console/Commands/StaticWarm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php index aaa78c323bf..e538f6cf013 100644 --- a/src/Console/Commands/StaticWarm.php +++ b/src/Console/Commands/StaticWarm.php @@ -287,7 +287,7 @@ private function shouldWarmPaginatedPages(Response $response, string $url): bool [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); return ! str_contains(parse_url($url, PHP_URL_QUERY) ?? '', "{$pageName}="); - } + } private function uriMatches($uri, $pattern): bool {