Skip to content

Menus: Fix wp_get_nav_menu_items() dropping items when the term count is stale or the query is not publish-only. - #13513

Open
MarcinDudekDev wants to merge 1 commit into
WordPress:trunkfrom
MarcinDudekDev:trac/66102
Open

MarcinDudekDev wants to merge 1 commit into
WordPress:trunkfrom
MarcinDudekDev:trac/66102

Conversation

@MarcinDudekDev

Copy link
Copy Markdown

Trac ticket

https://core.trac.wordpress.org/ticket/66102

What & why

wp_get_nav_menu_items() short-circuited to array() whenever the nav_menu term's cached count was 0:

if ( $menu->count > 0 ) {
    $items = get_posts( $args );
} else {
    $items = array();
}

$menu->count (maintained by _update_post_term_count()) counts only publish items and can be stale, but post_status is a documented argument of the function. So the gate returned array() in cases where get_posts() with the same args returns real items:

  • draft-only / trash-only menus queried with post_status = draft / any / publish,draft / trash
  • a published item attached under wp_defer_term_counting( true ) (bulk import) while the count is still 0
  • a stale WP_Term menu object (count captured before an item was added)

It also broke core itself: wp_update_nav_menu_item() positions new items via wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'publish,draft' ) ), so on a menu with no published items every added item received menu_order = 0 (colliding positions). _wp_auto_add_pages_to_menu() (same publish,draft query) could insert a duplicate item for the same page.

The gate was introduced in [changeset 556492a] (#55372) when the query was converted to tax_query, replacing the previous status-agnostic ! empty() check — i.e. it is a regression.

The fix

Remove the $menu->count short-circuit; always run get_posts( $args ). $menu->count is not a safe proxy for "no items match these args". get_posts() already returns array() for a genuinely empty menu, and that query is cached in the persistent post-queries group, so warm caches issue no extra query; a truly empty menu costs one cheap indexed query on a cold cache.

Testing

Adds 7 regression tests to Tests_Post_Nav_Menu (each fails on trunk, passes with the fix): draft-only queries (draft / any / publish,draft), trash-only + trash, wp_defer_term_counting, stale WP_Term, and the wp_update_nav_menu_item() position increment.

  • Tests_Post_Nav_Menu: pass (37 existing + 7 new)
  • --group menu: pass; --group customize: pass; REST menu controllers + nav-fallback/converter/editor: pass (762 tests green across groups)
  • phpcs --standard=phpcs.xml.dist: 0 errors

Perf: warm/object-cache sites — 0 extra queries (verified end-to-end, incl. a persistent drop-in); non-empty menus — identical; cache-less sites — +1 cheap indexed SELECT per empty rendered menu, per page. A post_status==='publish'-only "surgical" gate was evaluated and rejected: it still returns wrong array() under deferred counting / stale term snapshots (count=0 while publish items exist).


AI assistance: Yes
Tool(s): Claude Code (Anthropic), Devin
Model(s): Claude Opus 4.8 (orchestration), Devin (implementation + independent adversarial review)
Used for: Diagnosis (measured that the item-list query is already cached by WP_Query, redirecting the effort to this real gate bug), implementation of the one-line fix + regression tests, and multiple independent adversarial review rounds (regression sweep, perf measurement, negative controls). Reviewed and taking responsibility for the output.

🤖 Generated with Claude Code

…r the query is not publish-only.

wp_get_nav_menu_items() short-circuited to an empty array whenever
$menu->count was 0. That gate was introduced in [556492a] for #55372
(the tax_query conversion), replacing the previous status-agnostic
! empty() check on the queried items.

$menu->count is not a safe proxy for "no items match these args":

* _update_post_term_count() only counts 'publish' nav_menu_item posts,
  so a draft-only or trash-only menu reports count 0 even though a
  caller querying 'draft', 'any', 'publish,draft', or 'trash' has
  matching items.
* The count can be stale — wp_defer_term_counting() leaves it at 0
  while items are already attached, and wp_get_nav_menu_object()
  returns a passed WP_Term object verbatim, so a caller holding an
  old snapshot is also gated.

This also made wp_update_nav_menu_item() assign colliding
menu_order = 0 positions: it queries 'publish,draft' items to position
a new item, so a second draft on a draft-only menu could not be placed
after the first.

Remove the gate and always run the query, restoring pre-#55372
behavior. get_posts() already returns array() for a genuinely empty
menu, and the query is served from the persistent 'post-queries' cache
group, so warm caches do not gain a per-request query; one cold query
for a truly empty menu is the accepted tradeoff.

Adds 7 regression cases covering: draft-only menus queried with
'draft', 'any', and 'publish,draft'; menu_order positioning of a second
draft item; deferred term counting; stale WP_Term snapshots; and
trash-only menus queried with 'trash'. Each fails before this change
and passes after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props myththrazz, ugyensupport.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@dugyen

dugyen commented Sep 14, 2026

Copy link
Copy Markdown

Test report

Tested against a fresh wordpress-develop clone at trunk (ba9d19e), PHP 8.5.7, PHPUnit 9.6.36, MySQL.

Method: applied only the new test hunk to unpatched trunk first (source untouched) to confirm the 7 new tests reproduce the bug, then applied the source fix on top and re-ran, then ran the broader menu/customizer/REST/ajax suites for regressions.

Results:

Run Result
7 new regression tests, before fix 7 failed / 7 — reproduces the bug exactly as described (draft/any/publish,draft/trash queries and the stale-count/deferred-counting cases all return array() instead of the real items)
7 new regression tests, after fix 7 / 7 passed (36 assertions)
--group menu 112 / 112 passed (272 assertions)
--group customize 243 / 243 passed (2258 assertions)
REST menu controllers (wpRestMenusController, wpRestMenuItemsController, wpRestMenuLocationsController) 25 / 25 passed
wpCustomizeNavMenus.php (--group ajax) 28 / 28 passed
Full Tests_Post_Nav_Menu class (37 existing + 7 new) 44 / 44 passed
phpcs --standard=phpcs.xml.dist on the modified file 0 errors

Example before/after (test_wp_get_nav_menu_items_draft_only_menu_draft_query):

Before: Failed asserting that two arrays are identical.
  -Array &0 ( 0 => 4 )
  +Array &0 ()

After: OK

The diff also applied cleanly (git apply --check) against current trunk with no conflicts.

No regressions found across the surrounding menu/customizer/REST/ajax test surface. LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants