Skip to content

Menus: Do not gate wp_get_nav_menu_items() on the menu term count - #13596

Closed
rashmicodewing wants to merge 1 commit into
WordPress:trunkfrom
rashmicodewing:fix/66102-nav-menu-items-term-count-gate
Closed

rashmicodewing wants to merge 1 commit into
WordPress:trunkfrom
rashmicodewing:fix/66102-nav-menu-items-term-count-gate

Conversation

@rashmicodewing

Copy link
Copy Markdown

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

Problem

wp_get_nav_menu_items() short-circuits before running its item query whenever the menu term's count is 0:

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

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

  • It counts published items only. _update_post_term_count() counts posts with post_status = 'publish' (the default of the update_post_term_count_statuses filter), so a menu whose items are all draft or trash reports count = 0 even though a caller querying 'draft', 'any', 'publish,draft', or 'trash' has matching items.
  • It can be stale. wp_defer_term_counting( true ) leaves the count at 0 while items are already attached, and wp_get_nav_menu_object() returns a passed WP_Term object verbatim, so a caller holding a term snapshot taken before items were added is gated as well.

post_status is a documented argument of wp_get_nav_menu_items(), so all of the following return array() while get_posts() with the same arguments returns real items:

  • a draft-only menu queried with post_status 'draft', 'any', or 'publish,draft';
  • a trash-only menu queried with 'trash' (trashing the last published item returns the count to 0);
  • a published item attached while term counting is deferred — hidden even from the default 'publish' query;
  • any item hidden behind a stale WP_Term snapshot passed as $menu.

In-core consequences

  • Colliding menu_order. wp_update_nav_menu_item() positions a new item by querying 'publish,draft' items and using 1 + the last item's menu_order. On a menu with no published items — e.g. one being assembled as drafts — the gate empties the list, so the new item gets menu_order = 0; every subsequent item collides at position 0 instead of being appended.
  • Duplicate auto-added pages. _wp_auto_add_pages_to_menu() queries the 'publish,draft' item list to skip pages already in the menu. On a menu with no published items the check sees an empty list and inserts a second item for the same page.
  • Other in-core callers requesting non-default statuses are affected the same way: wp_get_nav_menu_to_edit() queries 'any' and wp_nav_menu_update_menu_items() queries 'draft,publish', so a draft-only menu lists empty on the edit screen and its items cannot be updated or deleted there.

Steps to reproduce

$menu_id = wp_create_nav_menu( 'example' );
$menu    = wp_get_nav_menu_object( $menu_id );

$item_id = wp_update_nav_menu_item(
        $menu_id,
        0,
        array(
                'menu-item-type'   => 'custom',
                'menu-item-title'  => 'Example',
                'menu-item-url'    => 'https://example.org/',
                'menu-item-status' => 'draft',
        )
);

// Returns array() — the gate short-circuits because $menu->count is 0:
$via_function = wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'draft' ) );

// Returns array( $item_id ) — the underlying query does find the item:
$via_query = wp_list_pluck(
        get_posts(
                array(
                        'post_type'   => 'nav_menu_item',
                        'post_status' => 'draft',
                        'nopaging'    => true,
                        'tax_query'   => array(
                                array(
                                        'taxonomy' => 'nav_menu',
                                        'field'    => 'term_taxonomy_id',
                                        'terms'    => $menu->term_taxonomy_id,
                                ),
                        ),
                )
        ),
        'ID'
);

Root cause

The gate was introduced in [53010] (556492a) for #55372, which replaced get_objects_in_term() + $args['include'] with a tax_query to save one query. The previous ! empty( $items ) check tested whether any objects were attached to the term at all — it was status-agnostic and reflected actual attachments. The replacement $menu->count > 0 tests the published count only and can additionally be stale, so the gate now fires in cases the old check never hit.

Fix

Remove the short-circuit and always run get_posts( $args ) — restoring the pre-[53010] approach of letting the query, not the term count, decide whether matching items exist. get_posts() already returns array() for a genuinely empty menu.

A narrower alternative — keeping the gate only for the default post_status === 'publish' query — was considered and rejected: deferred term counting and stale WP_Term snapshots both leave count = 0 while published items exist, so a publish-only gate would still return a wrong array() in precisely the cases this ticket covers.

Performance impact of removing the gate

  • Warm caches, and sites with a persistent object cache: zero additional queries after a one-time cache miss per menu — the query result, including the empty result, is cached in the post-queries group. (That result cache did not yet exist when the gate was added in 6.0.)
  • A site with no object cache: one additional indexed SELECT per empty rendered menu per request — it joins term_relationships and filters on term_taxonomy_id plus the type_status_date index, returning 0 rows.
  • Non-empty menus: identical — the gate already passed through to the same query.

Testing instructions

Run the new regression tests, which fail before the change and pass after:

phpunit --filter Tests_Post_Nav_Menu

Or reproduce manually with the snippet under "Steps to reproduce": before the change $via_function is empty while $via_query is not; after the change both return the same item.

Tests

Adds 8 regression cases to Tests_Post_Nav_Menu, each tagged @ticket 66102:

Test Covers
test_wp_get_nav_menu_items_returns_draft_items_from_uncounted_menu (x3, data provider) draft-only menu queried with 'draft', 'any', 'publish,draft'
test_wp_get_nav_menu_items_returns_trashed_items_from_uncounted_menu trash-only menu queried with 'trash'
test_wp_get_nav_menu_items_returns_items_while_term_counting_is_deferred wp_defer_term_counting( true )
test_wp_get_nav_menu_items_returns_items_for_stale_menu_object stale WP_Term snapshot passed as $menu
test_wp_update_nav_menu_item_appends_to_a_draft_only_menu menu_order positions stay distinct and ascending
test_wp_auto_add_pages_to_menu_does_not_duplicate_a_draft_item no duplicate auto-added page

Each of the 8 fails with the src/ change reverted and passes with it applied.

Locally (single site, PHP 8.2 / MySQL 8.0) the following all pass with the change applied: --group menu (113 tests), the customize suite (119), the REST menus controllers (79), and --group ajax (190, 1 pre-existing skip). PHPCS is clean on both changed files.

AI disclosure

AI assistance was used in preparing this patch, and is disclosed per the WordPress AI guidelines.

🤖 Generated with Claude Code

`wp_get_nav_menu_items()` short-circuited to an empty array whenever the
`nav_menu` term count was zero, before running its item query.

The term count is not a safe proxy for "no items match these args".
`_update_post_term_count()` counts published items only, so a menu holding
nothing but drafts reports a count of zero. The count can also be stale:
`wp_defer_term_counting( true )` leaves it at zero while items are already
attached, and `wp_get_nav_menu_object()` returns a passed `WP_Term` object
verbatim, so a snapshot taken before items were added gates the query too.

Since `post_status` is a documented argument of the function, this returned
an empty array for a draft-only menu queried with `draft`, `any`, or
`publish,draft`, for a trash-only menu queried with `trash`, and for
published items attached while term counting was deferred.

In core this made `wp_update_nav_menu_item()` position every new item at
`menu_order` 0 on a menu with no published items, and made
`_wp_auto_add_pages_to_menu()` insert a duplicate item for a page already
present as a draft. `wp_get_nav_menu_to_edit()` and
`wp_nav_menu_update_menu_items()` were affected the same way.

Always run the item query and let it decide whether matching items exist,
restoring the behaviour from before [53010].

Adds regression tests for each case.

Fixes #66102. See #55372.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

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.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @rashmicodewing.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

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

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@rashmicodewing

Copy link
Copy Markdown
Author

Closing this — it duplicates #13513, which was opened four days earlier by @MarcinDudekDev and makes the same change to wp_get_nav_menu_items() (removing the $menu->count > 0 gate so the item query always runs). I missed the existing PR on the ticket before opening this one; apologies for the noise.

#13513 already has a test report on it and should be the one reviewed for #66102. Deferring to it.

@rashmicodewing
rashmicodewing deleted the fix/66102-nav-menu-items-term-count-gate branch September 18, 2026 05:37
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.

1 participant