Repository navigation
Conversation
Add a test that `paginate_links()` returns null when the global query reports zero total pages, matching its behavior for a single total page. See #65817.
…e the baseline. `WP_Query::$max_num_pages` defaults to `0` and is never `null`, so the `?? 1` fallback in `paginate_links()` never ran. Use `?:` instead, which also falls back for `0`, and retire the now-empty baseline. See #65817.
…ort ternary. WordPress Coding Standards disallows `?:` (Universal.Operators.DisallowShortTernary). Use the `$x ? $x : $y` form already used elsewhere in core for a falsy fallback. See #65817.
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
|
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
|
|
||
| // Get max pages and current page out of the current query, if available. | ||
| $total = $wp_query->max_num_pages ?? 1; | ||
| $total = $wp_query->max_num_pages ? $wp_query->max_num_pages : 1; |
There was a problem hiding this comment.
This is equivalent to the following, right?
| $total = $wp_query->max_num_pages ? $wp_query->max_num_pages : 1; | |
| $total = $wp_query->max_num_pages === 0 ? $wp_query->max_num_pages : 1; |
There was a problem hiding this comment.
I mean, I see this calculated as the following, when it is set:
$this->max_num_pages = (int) ceil( $this->found_posts / $query_vars['posts_per_page'] );
Since $found_posts is int<0, max> and $query_vars['posts_per_page'] can only conceivable by int<1, max>, then $this->max_num_pages can also only be int<0, max>.
There was a problem hiding this comment.
In any case, leaving it as a falsy check is backwards-compatible in case someone did erroneously put null here.
Replacing `?? 1` with a plain ternary dropped the null-safe read of `$wp_query`, so `paginate_links()` would warn when the global query is not set (e.g. before `wp()` runs, or after a plugin unsets it). Check `$wp_query instanceof WP_Query` instead, which keeps the fallback to 1 and still satisfies PHPStan. Also cast to int and clamp to at least 1. Many plugins assign `ceil(...)` to `max_num_pages` without a cast, which yields a float, and a `found_posts` filter can drive the computed value negative. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
The `int<0, max>` type on `WP_Query::$max_num_pages` exposed that the computed value can go negative: a `found_posts` filter may return a negative count, and a filter-injected `LIMIT` on a `posts_per_page = -1` query divides by -1. Clamp the result with `max( 0, … )` so the property holds the type it declares. Also cast `posts_per_page` to int in the division. It is already an int by this point, so behavior is unchanged, but the query vars array is otherwise typed as mixed. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
The guard did not make `paginate_links()` tolerate a missing global query: the next line calls `get_query_var()`, which dereferences `$wp_query` unconditionally, so a null global fatals there either way. Keep only the int cast and the clamp to at least 1. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
A `found_posts` filter returning -5 with `posts_per_page` of 2 used to yield a `max_num_pages` of -2. Assert it is now clamped to 0, while the filtered `found_posts` value itself is kept. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
This comment has been minimized.
This comment has been minimized.
…ges(). `$query` is always a fresh `WP_Query` instance, so `max_num_pages` is always set -- the `isset()` branch was dead code, and the fallback to `1` could never run. Remove it and retire the now-unmatched baseline entry, rather than just patching the baseline regex to match the narrower `int<0, max>` type. See #65817.
|
While updating the baseline regex for the narrower b429cda drops the |
There was a problem hiding this comment.
🟡 Changes recommended
A requested regression test for the filtered non-empty LIMIT path with posts_per_page = -1 is still missing, leaving an active coverage gap for the described max_num_pages regression.
1 open issue
What changed in this PR
This pull request fixes PHPStan nullability findings around WP_Query::$max_num_pages, enforces non-negative page counts, and removes obsolete PHPStan baseline entries.
Changes:
- Normalizes pagination totals and preserves the documented fallback behavior.
- Annotates and clamps
WP_Query::$max_num_pagesto a non-negative range. - Removes redundant sitemap nullability handling.
- Deletes obsolete PHPStan baselines and adds regression coverage.
| File | Summary | Review notes |
|---|---|---|
tests/phpunit/tests/query/noFoundRows.php |
Tests clamping negative filtered post counts. | No additional comments. |
tests/phpunit/tests/general/paginateLinks.php |
Tests pagination behavior with zero maximum pages. | No additional comments. |
tests/phpstan/baselines/nullCoalesce.property.neon |
Removes the resolved PHPStan baseline. | No additional comments. |
tests/phpstan/baselines/isset.property.neon |
Removes the obsolete isset.property baseline entry. |
No additional comments. |
src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php |
Removes redundant nullable-property handling. | No additional comments. |
src/wp-includes/general-template.php |
Normalizes the pagination total with an integer cast and lower bound. | No additional comments. |
src/wp-includes/class-wp-query.php |
Documents and enforces the non-negative max_num_pages invariant. |
Moderate: Normalize cached max_num_pages values so legacy negative cache entries cannot violate the invariant. Nit: Add regression coverage for the injected LIMIT with posts_per_page = -1 path. |
phpstan.neon.dist |
Removes the obsolete PHPStan baseline include. | No additional comments. |
Review effort: Lite
The PR description calls out a second path to a negative max_num_pages: a `post_limits` filter injecting a non-empty LIMIT clause on a `posts_per_page = -1` query, which otherwise sets `nopaging` and never runs the found-rows calculation at all. Only the negative-found_posts path had a regression test. Verified this new test fails with -3 against the unclamped `(int) ceil(...)` and passes with the max( 0, ... ) clamp. See #65817.
|
🤖 Comment by Claude Opus 5.5 (medium effort) A code review of this branch turned up three possible follow-ups. None of them block this PR, since each would change behavior beyond the PHPStan fix. The biggest is that a Details1.
|
`paginate_links()` returns null for any total under 2, so the test passed both with and without the `max( 1, … )` clamp and could not catch a regression. The clamp's behavior is covered in `WP_Query` instead. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
The `?? 1` fallback, and the `isset()` ternary it replaced, only ever applied to an unset property, which never happens on `WP_Query`. So a `max_num_pages` of 0 always passed through as 0, and clamping the default to at least 1 changed nothing observable: the function returns null for any total under 2. The cast is unnecessary as well, since `$args['total']` is cast to int before it is used. Co-Authored-By: Claude Opus 5.5 (medium effort) <noreply@anthropic.com> Claude-Code-Session-Id: 92a8904b-c6d4-4ac3-8ba5-7d8209aed336
Since `WP_Query::$max_num_pages` is never `null`, the `?? 1` fallback in `paginate_links()` never applied, so the property is now read directly. The property is also documented as a non-negative integer. This exposed that `WP_Query::set_found_posts()` could compute a negative page count when a `found_posts` filter returns a negative number, or when a filter injects a `LIMIT` into a query with a `posts_per_page` of `-1`, so that result is now clamped to zero. For the same reason the unreachable `isset()` fallback in `WP_Sitemaps_Posts::get_max_num_pages()` is removed, retiring its baseline entry. Developed in #15475. Follow-up to r48476, r57648, r61454, r65192. Props bgturner, westonruter. See #65817. git-svn-id: https://develop.svn.wordpress.org/trunk@65281 602fd350-edb4-49c9-b593-d223f7449a82
Since `WP_Query::$max_num_pages` is never `null`, the `?? 1` fallback in `paginate_links()` never applied, so the property is now read directly. The property is also documented as a non-negative integer. This exposed that `WP_Query::set_found_posts()` could compute a negative page count when a `found_posts` filter returns a negative number, or when a filter injects a `LIMIT` into a query with a `posts_per_page` of `-1`, so that result is now clamped to zero. For the same reason the unreachable `isset()` fallback in `WP_Sitemaps_Posts::get_max_num_pages()` is removed, retiring its baseline entry. Developed in WordPress/wordpress-develop#15475. Follow-up to r48476, r57648, r61454, r65192. Props bgturner, westonruter. See #65817. Built from https://develop.svn.wordpress.org/trunk@65281 git-svn-id: http://core.svn.wordpress.org/trunk@64430 1a063a9b-81f0-0310-95a4-ce76da25c4cd

✅ Committed in r65281 (7032470).
Trac ticket: https://core.trac.wordpress.org/ticket/65817
Replaces #15474, closed when renaming its branch unexpectedly closed the PR instead of retargeting it.
Fixes the one remaining
nullCoalesce.propertyerror and removestests/phpstan/baselines/nullCoalesce.property.neon.The error
WP_Query::$max_num_pagesdefaults to0and is nevernull, so?? 1inpaginate_links()never ran.The fix
In
paginate_links(), the property is read directly:The
?? 1, like theisset()ternary it replaced in r61454, only ever applied to an unset property, so amax_num_pagesof0already passed through as0. This line has no runtime effect. No cast is needed either: the function casts$args['total']tointbefore using it, which also covers plugins that assign an uncastceil( … )(a float) to the property.In
WP_Query:$max_num_pagesgets@phpstan-var int<0, max>, which records what core already assumes about it.found_postsfilter that returns a negative count, or a filter-injectedLIMITon aposts_per_page = -1query, mademax_num_pagesnegative (e.g.ceil( -5 / 2 ) = -2,ceil( 3 / -1 ) = -3).set_found_posts()now clamps the result withmax( 0, … ).In
WP_Sitemaps_Posts::get_max_num_pages(), theisset()fallback is removed for the same reason:$queryis always a freshWP_Query, somax_num_pagesis always set. This retires itsisset.propertybaseline entry.Why this is safe
This is narrower than the attempt in #13982, where a reviewer asked for this exact line to be reverted because it also added a null guard around
$wp_queryitself. This PR doesn't touch$wp_query's nullability. A null global query was never supported here anyway. The next line callsget_query_var( 'paged' ), which dereferences$wp_queryunconditionally, sopaginate_links()fatals without a global query both before and after this change.The
paginate_links()and sitemaps changes don't change behavior. The clamp only affects values that used to be negative, and core's pagination consumers treat0the same as a negative total. Possible follow-ups are listed in a comment below.Tests
Tests_Query_NoFoundRows::test_max_num_pages_is_not_negative_when_found_posts_is_filtered_negativecovers the clamp for a negativefound_posts. It fails on trunk (-2) and passes here.Tests_Query_NoFoundRows::test_max_num_pages_is_not_negative_when_limits_are_filtered_with_postsperpage_minus1covers the filteredLIMITon aposts_per_page = -1query. It fails on trunk (-3) and passes here.No
paginate_links()test is included, because its line has no runtime effect, so no test could fail on trunk and pass here.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5 (medium effort)
Used for: Code review of the branch, follow-up commits (the
paginate_links()simplification, themax( 0, … )clamp inWP_Query::set_found_posts(), and its test), and this description. A survey of plugin code with Veloria showed which values plugins write to$max_num_pages. All changes were reviewed by a human.This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.