Skip to content
36 changes: 31 additions & 5 deletions src/wp-includes/feed.php
Original file line number Diff line number Diff line change
Expand Up @@ -720,20 +720,46 @@ function get_feed_build_date( $format ) {
$utc = new DateTimeZone( 'UTC' );

if ( ! empty( $wp_query ) && $wp_query->have_posts() ) {
// Extract the post modified times from the posts.
$modified_times = wp_list_pluck( $wp_query->posts, 'post_modified_gmt' );
/*
* Resolve each entry to a WP_Post so we can read post_modified_gmt.
* Supports queries using fields => 'ids' where $posts contains
* integers, as well as the default WP_Post objects.
*/
$modified_times = array_filter(
array_map(
static function ( $post ) {
$post_object = get_post( $post );
return $post_object instanceof WP_Post ? $post_object->post_modified_gmt : null;
},
$wp_query->posts
)
);

// If this is a comment feed, check those objects too.
if ( $wp_query->is_comment_feed() && $wp_query->comment_count ) {
// Extract the comment modified times from the comments.
$comment_times = wp_list_pluck( $wp_query->comments, 'comment_date_gmt' );
/*
* Resolve each entry to a WP_Comment so we can read
* comment_date_gmt. Supports comment queries using
* fields => 'ids' as well as the default WP_Comment objects.
*/
$comment_times = array_filter(
array_map(
static function ( $comment ) {
$comment_object = get_comment( $comment );
return $comment_object instanceof WP_Comment ? $comment_object->comment_date_gmt : null;
},
$wp_query->comments
)
);

// Add the comment times to the post times for comparison.
$modified_times = array_merge( $modified_times, $comment_times );
}

// Determine the maximum modified time.
$datetime = date_create_immutable_from_format( 'Y-m-d H:i:s', max( $modified_times ), $utc );
if ( $modified_times ) {
$datetime = date_create_immutable_from_format( 'Y-m-d H:i:s', max( $modified_times ), $utc );
}
}

if ( false === $datetime ) {
Expand Down
87 changes: 87 additions & 0 deletions tests/phpunit/tests/date/getFeedBuildDate.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,93 @@ public function test_should_return_correct_feed_build_date() {
$this->assertSame( '2018-07-23T03:13:23+00:00', get_feed_build_date( DATE_RFC3339 ) );
}

/**
* Test that get_feed_build_date() does not throw a ValueError
* when $wp_query->posts contains no entries that resolve to a
* WP_Post (e.g. invalid IDs that get_post() returns null for).
*
* @ticket 59956
*/
public function test_should_not_error_when_modified_times_is_empty() {
global $wp_query;

$datetime = new DateTimeImmutable( 'now', wp_timezone() );
$datetime_utc = $datetime->setTimezone( new DateTimeZone( 'UTC' ) );

self::factory()->post->create(
array(
'post_date' => $datetime->format( 'Y-m-d H:i:s' ),
)
);

/*
* Build a WP_Query where have_posts() is true but no entry can be
* resolved to a WP_Post. Setting post_count without populating posts
* with valid data exercises the empty $modified_times fallback path.
*/
$wp_query = new WP_Query();
$wp_query->post_count = 1;
$wp_query->posts = array( PHP_INT_MAX ); // Non-existent post ID.

Comment on lines +53 to +70
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test currently uses query_posts( [ 'fields' => 'ids' ] ) to force wp_list_pluck() to return an empty array, which requires setExpectedIncorrectUsage( 'WP_List_Util::pluck' ) and depends on a DB query plus an unrelated _doing_it_wrong() path. You can simulate the reported inconsistency more directly (and avoid the incorrect-usage expectation) by setting global $wp_query to a fresh WP_Query with post_count = 1 (so have_posts() is true) but an empty posts array (so wp_list_pluck() returns []). This keeps the test focused on guarding max() against empty input.

Suggested change
$datetime = new DateTimeImmutable( 'now', wp_timezone() );
$datetime_utc = $datetime->setTimezone( new DateTimeZone( 'UTC' ) );
$id = self::factory()->post->create(
array(
'post_date' => $datetime->format( 'Y-m-d H:i:s' ),
)
);
// Use a query where have_posts() is true but wp_list_pluck()
// returns an empty array because the posts array contains scalar
// values (neither objects nor arrays). This triggers the _doing_it_wrong
// notice in WP_List_Util::pluck() and produces an empty result.
query_posts(
array(
'posts__in' => array( $id ),
'fields' => 'ids',
)
);
$this->setExpectedIncorrectUsage( 'WP_List_Util::pluck' );
global $wp_query;
$datetime = new DateTimeImmutable( 'now', wp_timezone() );
$datetime_utc = $datetime->setTimezone( new DateTimeZone( 'UTC' ) );
self::factory()->post->create(
array(
'post_date' => $datetime->format( 'Y-m-d H:i:s' ),
)
);
// Simulate a query where have_posts() is true but wp_list_pluck()
// receives an empty posts array, so modified_times is empty.
$wp_query = new WP_Query();
$wp_query->post_count = 1;
$wp_query->posts = array();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chubes4 Would this replicate the original scenario that you've encountered? Curious what your WP_Query has in it which caused this issue in the first place.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that does not replicate the original scenario.

The specific query that triggers it can be observed here: https://github.com/Extra-Chill/data-machine-events/blob/main/inc/Core/Event_Post_Type.php#L153-L180

We use a custom artist taxonomy, and a custom data_machine_events post type.

When there are existing data_machine_events posts using a given artist term, but 0 regular posts, and we query a feed URL with fields => 'ids' set, WP_Query returns an array of scalar ids instead of WP_Post objects.

wp_list_pluck() requires each element to be an object or array, so it hits _doing_it_wrong and returns an empty array. max([]) then fatals.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this masking maybe a lower level issue? Shouldn't this also ensure that get_feed_build_date() works when posts is a list of post IDs and not WP_Post objects? This would fix the underlying issue.

Now, it would sense still to include the check added in this PR to prevent passing an empty array to max(), but I see this as additional hardening on top of a more fundamental issue that can be fixed.

Copy link
Copy Markdown
Member

@westonruter westonruter Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may be needed in both of these places:

$modified_times = wp_list_pluck( $wp_query->posts, 'post_modified_gmt' );

$comment_times = wp_list_pluck( $wp_query->comments, 'comment_date_gmt' );

Instead of using wp_list_pluck(), it should be using array_map() and just pass through the value if it is an integer. Otherwise, it can return the ID property of the respective object.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The original fix only stopped the fatal, but it returned the global last modified time instead of the expected post. The bug was deeper down, and now it returns the timestamp for the correct post.

$result = get_feed_build_date( DATE_RFC3339 );
Comment thread
westonruter marked this conversation as resolved.
$this->assertIsString( $result );

$this->assertEqualsWithDelta(
strtotime( $datetime_utc->format( DATE_RFC3339 ) ),
strtotime( $result ),
2,
'Should fall back to last post modified when modified_times is empty.'
);
}

/**
* Test that get_feed_build_date() returns the correct modified time
* when $wp_query->posts is an array of post IDs (from fields => 'ids')
* instead of WP_Post objects.
*
* Before this test, the function would fall back to get_lastpostmodified()
* and silently return the site-wide latest modified time instead of the
* latest modified time of the posts actually in the feed.
*
* @ticket 59956
*/
public function test_should_return_correct_build_date_for_id_only_query() {
// Create two posts with different modified times. Post B is newer than Post A.
$older_post_id = self::factory()->post->create(
array(
'post_date' => '2020-01-01 00:00:00',
'post_date_gmt' => '2020-01-01 00:00:00',
)
);

self::factory()->post->create(
array(
'post_date' => '2024-06-15 12:00:00',
'post_date_gmt' => '2024-06-15 12:00:00',
)
);

/*
* Query for ONLY the older post using fields => 'ids'. The feed's
* <lastBuildDate> must reflect the modified time of the older post,
* not the site-wide latest (which would be the newer post that is
* not in the feed).
*/
global $wp_query;
$wp_query = new WP_Query(
array(
'p' => $older_post_id,
'fields' => 'ids',
)
);

$this->assertSame(
'2020-01-01T00:00:00+00:00',
get_feed_build_date( DATE_RFC3339 ),
'Build date should match the modified time of the post in the feed, not the site-wide latest.'
);
}

/**
* Test that get_feed_build_date() works with invalid post dates.
*
Expand Down
Loading