WordPress 6.9: Understanding the Adjacent Post Navigation Fix and Plugin Compatibility Challenges

WordPress Development

WordPress 6.9's fix for adjacent post navigation introduced an ID-based comparison, unintentionally causing infinite loops in plugins that modify the `get_adjacent_post()` WHERE clause. This article details the change, its impact, and provides solutions for developers to ensure compatibility.

WordPress 6.9 introduced a critical fix for adjacent post navigation, particularly when multiple posts shared identical publication dates. While this update resolved a long-standing bug, it inadvertently created a new challenge: infinite loops in certain extensions that modify the get_adjacent_post() WHERE clause.

What Changed in WordPress 6.9

In WordPress 6.9 (Trac #8107), a bug fix was implemented to address cases where next/previous post navigation failed when posts had identical post_date values, a common scenario when bulk-publishing draft posts.

The Technical Change

The get_adjacent_post() function's WHERE clause was modified to include an ID-based comparison as a tiebreaker:

Before (WordPress 6.8 and earlier):

WHERE p.post_date > '2024-01-01 12:00:00' 
  AND p.post_type = 'post'

After (WordPress 6.9):

WHERE (
    p.post_date > '2024-01-01 12:00:00' 
    OR (
      p.post_date = '2024-01-01 12:00:00' 
      AND p.ID > 123
    )
  ) 
  AND p.post_type = 'post'

This change ensures deterministic ordering for posts with identical dates by using the post ID as a secondary sorting criterion.

Additionally, the ORDER BY clause was updated:

Before:

ORDER BY p.post_date DESC 
LIMIT 1

After:

ORDER BY p.post_date DESC, 
         p.ID DESC 
LIMIT 1

The Problem: Infinite Loops in Some Themes/Plugins

As Trac ticket #64390 documents, some plugins and themes modify adjacent post navigation behavior. For instance, WooCommerce's Storefront theme navigates between products instead of standard posts. These extensions often use the get_{$adjacent}_post_where filter to replace the current post's date with a different post's date.

Here's how the issue manifested:

  1. A plugin hooks into the get_previous_post_where filter.
  2. The plugin performs string replacement, changing $current_post->post_date to $target_product->post_date.
  3. The Problem: The new WHERE clause structure includes the post date in two places (for date comparison and for ID comparison).
  4. Simple string replacement only modified the date comparison, leaving the ID comparison unchanged.
  5. The query repeatedly returned the same post, leading to an infinite loop.

Real-World Example: Storefront Theme

The fix implemented for the WooCommerce Storefront theme illustrates this issue. They had to add specific handling for the ID comparison:

// Replace the post date (works as before)
$where = str_replace( $post->post_date, $new->post_date, $where );

// NEW: Also need to replace the ID comparison (WordPress 6.9+)
if ( strpos( $where, 'AND p.ID ' ) !== false ) {
    $search = sprintf( 'AND p.ID %s ', $this->previous ? '<' : '>' );
    $target = $search . $post->ID;
    $replace = $search . $new->ID;
    $where = str_replace( $target, $replace, $where );
}

For Plugin Developers: Detecting and Fixing the Issue

How to Detect If Your Plugin Is Affected

Your plugin is likely affected if it:

  • Uses the get_{$adjacent}_post_where filter.
  • Performs string replacement on post dates within the WHERE clause.
  • Modifies which post is considered “adjacent” (e.g., navigating between custom post types).

To test your plugin's compatibility with WordPress 6.9:

  1. Create 3-4 posts with identical publication dates (e.g., by bulk publishing drafts).
  2. Navigate between these posts using your plugin’s adjacent post functionality.
  3. Verify that navigation moves to different posts, not the same post repeatedly.
  4. Check for infinite loops or performance degradation.

Quick Fix: Handle the ID Comparison

If you are replacing dates in the WHERE clause, you must also handle the ID comparison. The following example, loosely inspired by Storefront’s recent fix, provides a common approach:

add_filter( 'get_next_post_where', 'example_custom_adjacent_post_where', 10, 5 );
add_filter( 'get_previous_post_where', 'example_custom_adjacent_post_where', 10, 5 );

function example_custom_adjacent_post_where( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) {

    // IMPORTANT: Replace this with your logic to find the desired adjacent post.
    $adjacent_post = example_find_adjacent_post_function( $post );
	
    if ( $adjacent_post instanceof WP_Post ) {
        // Replace the date comparison.
        $where = str_replace( $post->post_date, $adjacent_post->post_date, $where );

        // Replace the post ID in the comparison.
        $where = preg_replace(
            "/AND p\.ID (<|>) {$post->ID}\)/",
            "AND p.ID $1 {$adjacent_post->ID})",
            $where
        );
    }

    return $where;
}

You could also integrate a version_compare( $wp_version, '6.9', '>=' ) test to support multiple WordPress versions. Remember to thoroughly test any code changes on your site and customize them to fit your specific needs.

Future Communication and Support

While initially categorized as a bug fix, this change highlights the need for broader communication regarding WP_Query and SQL modifications. Going forward, the WordPress core team aims to:

  • Reach out proactively to known plugins utilizing the get_{$adjacent}_post_where filter.
  • Include relevant migration guidance in the 6.9 field guide (as applicable).
  • Test against popular plugins that modify adjacent post queries.

Discussions are ongoing on the Trac ticket for making this functionality more robust in future WordPress versions. If you are experiencing issues:

Your patience and understanding are appreciated. If you maintain a plugin affected by this change, please update it using the provided guidance, and reach out for assistance if needed.