From 4a2c9b4f53df80492547b0171905aee55ec5b361 Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 17 Mar 2026 18:19:04 +0100 Subject: [PATCH 01/11] Meta: Allow registering meta keys that skip query cache invalidation Add `invalidates_query_cache` parameter to `register_meta()`. Meta keys registered with this set to `false` will not bump the `last_changed` timestamp for the parent object's cache group when added, updated, or deleted. This prevents high-frequency meta writes from invalidating query caches site-wide. Meta keys registered as non-query-cacheable are also blocked from use in `WP_Meta_Query` clauses, since skipping cache invalidation would lead to stale query results. Registers the real-time collaboration meta keys (`wp_sync_awareness` and `wp_sync_update`) with this flag to resolve the cache invalidation issue described in https://core.trac.wordpress.org/ticket/64696. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-meta-query.php | 31 +++++++++++++ src/wp-includes/default-filters.php | 6 +-- src/wp-includes/meta.php | 58 ++++++++++++++++++++----- src/wp-includes/post.php | 32 +++++++++++++- 4 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index 67e2d3d27ee0b..5ea0e3274a76d 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -95,6 +95,14 @@ class WP_Meta_Query { */ protected $has_or_relation = false; + /** + * The type of object metadata is for (e.g. 'post', 'user', 'term'). + * + * @since 7.0.0 + * @var string + */ + protected $object_type = ''; + /** * Constructor. * @@ -364,6 +372,7 @@ public function get_sql( $type, $primary_table, $primary_id_column, $context = n $this->meta_table = $meta_table; $this->meta_id_column = sanitize_key( $type . '_id' ); + $this->object_type = $type; $this->primary_table = $primary_table; $this->primary_id_column = $primary_id_column; @@ -533,6 +542,28 @@ protected function get_sql_for_query( &$query, $depth = 0 ) { public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) { global $wpdb; + // Refuse to query by a meta key registered as non-query-cacheable. + if ( $this->object_type + && isset( $clause['key'] ) + && is_string( $clause['key'] ) + && ! wp_meta_key_invalidates_query_cache( $this->object_type, $clause['key'] ) + ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The meta key. */ + __( 'The meta key "%s" is registered with invalidates_query_cache set to false and cannot be used in meta queries.' ), + $clause['key'] + ), + '7.0.0' + ); + + return array( + 'join' => array(), + 'where' => array(), + ); + } + $sql_chunks = array( 'where' => array(), 'join' => array(), diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index a1c2e4d93df87..e53580093bd4f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -122,9 +122,9 @@ } // Post meta. -add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed' ); -add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed' ); -add_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed' ); +add_action( 'added_post_meta', 'wp_cache_set_posts_last_changed', 10, 3 ); +add_action( 'updated_post_meta', 'wp_cache_set_posts_last_changed', 10, 3 ); +add_action( 'deleted_post_meta', 'wp_cache_set_posts_last_changed', 10, 3 ); // User meta. add_action( 'added_user_meta', 'wp_cache_set_users_last_changed' ); diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index c657c6c2e7af3..3ee963d2085be 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -1440,16 +1440,17 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) { } $defaults = array( - 'object_subtype' => '', - 'type' => 'string', - 'label' => '', - 'description' => '', - 'default' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => null, - 'show_in_rest' => false, - 'revisions_enabled' => false, + 'object_subtype' => '', + 'type' => 'string', + 'label' => '', + 'description' => '', + 'default' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => null, + 'show_in_rest' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ); // There used to be individual args for sanitize and auth callbacks. @@ -1644,6 +1645,43 @@ function registered_meta_key_exists( $object_type, $meta_key, $object_subtype = return isset( $meta_keys[ $meta_key ] ); } +/** + * Checks whether a meta key invalidates query caches when updated. + * + * Meta keys registered with `'invalidates_query_cache' => false` will not + * cause query cache invalidation when added, updated, or deleted. This is + * useful for high-frequency meta that is never used in query filters, such + * as real-time collaboration sync data. + * + * Unregistered meta keys are assumed to invalidate query caches. + * + * @since 7.0.0 + * + * @param string $object_type Type of object metadata is for. Accepts 'post', 'comment', 'term', 'user', + * or any other object type with an associated meta table. + * @param string $meta_key Metadata key. + * @param string $object_subtype Optional. The subtype of the object type. Default empty string. + * @return bool True if the meta key invalidates query caches, false otherwise. + */ +function wp_meta_key_invalidates_query_cache( $object_type, $meta_key, $object_subtype = '' ) { + $meta_keys = get_registered_meta_keys( $object_type, $object_subtype ); + + if ( isset( $meta_keys[ $meta_key ] ) ) { + return $meta_keys[ $meta_key ]['invalidates_query_cache']; + } + + // Also check keys registered without a subtype. + if ( '' !== $object_subtype ) { + $meta_keys = get_registered_meta_keys( $object_type ); + if ( isset( $meta_keys[ $meta_key ] ) ) { + return $meta_keys[ $meta_key ]['invalidates_query_cache']; + } + } + + // Unregistered keys default to invalidating caches. + return true; +} + /** * Unregisters a meta key from the list of registered keys. * diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 90b6c5e9e93e5..2fdc27c65284f 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -690,6 +690,22 @@ function create_initial_post_types() { 'supports' => array( 'custom-fields' ), ) ); + + register_post_meta( + 'wp_sync_storage', + 'wp_sync_awareness', + array( + 'invalidates_query_cache' => false, + ) + ); + + register_post_meta( + 'wp_sync_storage', + 'wp_sync_update', + array( + 'invalidates_query_cache' => false, + ) + ); } register_post_status( @@ -8440,9 +8456,23 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) { /** * Sets the last changed time for the 'posts' cache group. * + * When called from a post meta action (`added_post_meta`, `updated_post_meta`, + * or `deleted_post_meta`), the meta key is checked against the registered meta + * registry. If the meta key was registered with `'invalidates_query_cache' => false`, + * the cache is not invalidated. This allows high-frequency meta writes (e.g. + * real-time collaboration data) to avoid invalidating query caches site-wide. + * * @since 5.0.0 + * @since 7.0.0 Added the `$meta_id`, `$object_id`, and `$meta_key` parameters. + * + * @param int $meta_id Optional. Meta ID. Passed by meta action hooks. + * @param int $object_id Optional. Object ID. Passed by meta action hooks. + * @param string $meta_key Optional. Meta key. Passed by meta action hooks. */ -function wp_cache_set_posts_last_changed() { +function wp_cache_set_posts_last_changed( $meta_id = 0, $object_id = 0, $meta_key = '' ) { + if ( $meta_key && ! wp_meta_key_invalidates_query_cache( 'post', $meta_key ) ) { + return; + } wp_cache_set_last_changed( 'posts' ); } From 547d5564f9a2ab2cbf43cad6dee5c9b07f441819 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 18 Mar 2026 16:13:25 +0100 Subject: [PATCH 02/11] Register RTC meta keys for all post types Use empty string subtype so the keys are found during cache invalidation lookups, which do not have access to the post type. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/post.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2fdc27c65284f..2344cda92c7b2 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -692,7 +692,7 @@ function create_initial_post_types() { ); register_post_meta( - 'wp_sync_storage', + '', 'wp_sync_awareness', array( 'invalidates_query_cache' => false, @@ -700,7 +700,7 @@ function create_initial_post_types() { ); register_post_meta( - 'wp_sync_storage', + '', 'wp_sync_update', array( 'invalidates_query_cache' => false, From 5a78b63775f1ba0221f8c51b75179ed5f5f31b84 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 18 Mar 2026 16:31:00 +0100 Subject: [PATCH 03/11] Tests: Add tests for invalidates_query_cache meta registration Co-Authored-By: Claude Opus 4.6 --- .../tests/meta/invalidatesQueryCache.php | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/phpunit/tests/meta/invalidatesQueryCache.php diff --git a/tests/phpunit/tests/meta/invalidatesQueryCache.php b/tests/phpunit/tests/meta/invalidatesQueryCache.php new file mode 100644 index 0000000000000..8b387b4283de4 --- /dev/null +++ b/tests/phpunit/tests/meta/invalidatesQueryCache.php @@ -0,0 +1,196 @@ +post->create(); + } + + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post_id, true ); + } + + public function tear_down() { + unregister_meta_key( 'post', 'nocache_meta' ); + unregister_meta_key( 'post', 'normal_meta' ); + parent::tear_down(); + } + + /** + * The `invalidates_query_cache` argument should default to true. + */ + public function test_default_value_is_true() { + register_post_meta( '', 'normal_meta', array() ); + + $meta_keys = get_registered_meta_keys( 'post' ); + $this->assertTrue( $meta_keys['normal_meta']['invalidates_query_cache'] ); + } + + /** + * The `invalidates_query_cache` argument should be stored when set to false. + */ + public function test_registered_as_false() { + register_post_meta( + '', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + $meta_keys = get_registered_meta_keys( 'post' ); + $this->assertFalse( $meta_keys['nocache_meta']['invalidates_query_cache'] ); + } + + /** + * Adding post meta for a non-cacheable key should not bump last_changed. + */ + public function test_add_post_meta_does_not_invalidate_cache() { + register_post_meta( + '', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + // Prime the last_changed value. + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + usleep( 1000 ); + add_post_meta( self::$post_id, 'nocache_meta', 'value1' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertSame( $before, $after, 'last_changed should not change for non-cacheable meta.' ); + } + + /** + * Updating post meta for a non-cacheable key should not bump last_changed. + */ + public function test_update_post_meta_does_not_invalidate_cache() { + register_post_meta( + '', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + add_post_meta( self::$post_id, 'nocache_meta', 'value1' ); + + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + usleep( 1000 ); + update_post_meta( self::$post_id, 'nocache_meta', 'value2' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertSame( $before, $after, 'last_changed should not change for non-cacheable meta.' ); + } + + /** + * Deleting post meta for a non-cacheable key should not bump last_changed. + */ + public function test_delete_post_meta_does_not_invalidate_cache() { + register_post_meta( + '', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + add_post_meta( self::$post_id, 'nocache_meta', 'value1' ); + + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + usleep( 1000 ); + delete_post_meta( self::$post_id, 'nocache_meta' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertSame( $before, $after, 'last_changed should not change for non-cacheable meta.' ); + } + + /** + * Regular meta should still invalidate the cache as before. + */ + public function test_regular_meta_still_invalidates_cache() { + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + // Small sleep to ensure microtime differs. + usleep( 1000 ); + add_post_meta( self::$post_id, 'regular_unregistered_meta', 'value1' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertNotSame( $before, $after, 'last_changed should change for regular meta.' ); + } + + /** + * Meta registered with invalidates_query_cache true should still invalidate. + */ + public function test_registered_cacheable_meta_still_invalidates_cache() { + register_post_meta( + '', + 'normal_meta', + array( 'invalidates_query_cache' => true ) + ); + + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + usleep( 1000 ); + add_post_meta( self::$post_id, 'normal_meta', 'value1' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertNotSame( $before, $after, 'last_changed should change for cacheable meta.' ); + } + + /** + * WP_Meta_Query should refuse to query by a non-cacheable meta key. + * + * @expectedIncorrectUsage WP_Meta_Query::get_sql_for_clause + */ + public function test_meta_query_refuses_non_cacheable_key() { + register_post_meta( + '', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + $meta_query = new WP_Meta_Query( + array( + array( + 'key' => 'nocache_meta', + 'value' => 'test', + ), + ) + ); + + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' ); + + $this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should not appear in WHERE clause.' ); + $this->assertStringNotContainsString( 'nocache_meta', $sql['join'], 'Non-cacheable meta key should not appear in JOIN clause.' ); + } + + /** + * WP_Meta_Query should work normally for regular meta keys. + */ + public function test_meta_query_allows_regular_key() { + $meta_query = new WP_Meta_Query( + array( + array( + 'key' => 'some_regular_key', + 'value' => 'test', + ), + ) + ); + + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' ); + + $this->assertStringContainsString( 'some_regular_key', $sql['where'], 'Regular meta key should appear in WHERE clause.' ); + } +} From cccef6fa26361e51787d26f214583191302e1ba9 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 18 Mar 2026 16:35:39 +0100 Subject: [PATCH 04/11] Support subtype-registered meta keys in cache invalidation lookup Search across all registered subtypes when the caller does not provide one, so that keys registered for a specific post type are still found by wp_cache_set_posts_last_changed which only has the meta key. Adds tests for subtype registration. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/meta.php | 11 +++++ .../tests/meta/invalidatesQueryCache.php | 47 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index 3ee963d2085be..acea0c0f5ae75 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -1664,6 +1664,8 @@ function registered_meta_key_exists( $object_type, $meta_key, $object_subtype = * @return bool True if the meta key invalidates query caches, false otherwise. */ function wp_meta_key_invalidates_query_cache( $object_type, $meta_key, $object_subtype = '' ) { + global $wp_meta_keys; + $meta_keys = get_registered_meta_keys( $object_type, $object_subtype ); if ( isset( $meta_keys[ $meta_key ] ) ) { @@ -1678,6 +1680,15 @@ function wp_meta_key_invalidates_query_cache( $object_type, $meta_key, $object_s } } + // When no subtype is given, search across all registered subtypes. + if ( '' === $object_subtype && is_array( $wp_meta_keys ) && isset( $wp_meta_keys[ $object_type ] ) ) { + foreach ( $wp_meta_keys[ $object_type ] as $registered_keys ) { + if ( isset( $registered_keys[ $meta_key ] ) ) { + return $registered_keys[ $meta_key ]['invalidates_query_cache']; + } + } + } + // Unregistered keys default to invalidating caches. return true; } diff --git a/tests/phpunit/tests/meta/invalidatesQueryCache.php b/tests/phpunit/tests/meta/invalidatesQueryCache.php index 8b387b4283de4..c0ffaa8f303c6 100644 --- a/tests/phpunit/tests/meta/invalidatesQueryCache.php +++ b/tests/phpunit/tests/meta/invalidatesQueryCache.php @@ -21,6 +21,7 @@ public static function wpTearDownAfterClass() { public function tear_down() { unregister_meta_key( 'post', 'nocache_meta' ); + unregister_meta_key( 'post', 'nocache_meta', 'post' ); unregister_meta_key( 'post', 'normal_meta' ); parent::tear_down(); } @@ -193,4 +194,50 @@ public function test_meta_query_allows_regular_key() { $this->assertStringContainsString( 'some_regular_key', $sql['where'], 'Regular meta key should appear in WHERE clause.' ); } + + /** + * A key registered for a specific post type should skip cache invalidation. + */ + public function test_subtype_registration_skips_cache_invalidation() { + register_post_meta( + 'post', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + usleep( 1000 ); + add_post_meta( self::$post_id, 'nocache_meta', 'value1' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertSame( $before, $after, 'last_changed should not change for non-cacheable meta registered on a specific post type.' ); + } + + /** + * A key registered for a specific post type should be refused in meta queries. + * + * @expectedIncorrectUsage WP_Meta_Query::get_sql_for_clause + */ + public function test_subtype_registration_refuses_meta_query() { + register_post_meta( + 'post', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + $meta_query = new WP_Meta_Query( + array( + array( + 'key' => 'nocache_meta', + 'value' => 'test', + ), + ) + ); + + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' ); + + $this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should not appear in WHERE clause.' ); + } } From cf75d00f3427ca93613db289d86d34dd270bd95e Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 18 Mar 2026 17:08:57 +0100 Subject: [PATCH 05/11] Support per-post-type invalidates_query_cache behavior Use get_post_type() in wp_cache_set_posts_last_changed to check the exact subtype when deciding whether to skip cache invalidation. Extract post types from the parent WP_Query context in WP_Meta_Query so that a key registered as non-cacheable for one post type is still allowed in queries targeting a different post type. Adds tests for per-subtype behavior: matching post type, different post type, array of post types, and cache invalidation with mismatched post type. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-meta-query.php | 48 +++++++++- src/wp-includes/post.php | 7 +- .../tests/meta/invalidatesQueryCache.php | 93 ++++++++++++++++++- 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index 5ea0e3274a76d..a22fde3c70489 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -103,6 +103,14 @@ class WP_Meta_Query { */ protected $object_type = ''; + /** + * Object subtypes for the query (e.g. post types). + * + * @since 7.0.0 + * @var string[] + */ + protected $object_subtypes = array(); + /** * Constructor. * @@ -374,6 +382,17 @@ public function get_sql( $type, $primary_table, $primary_id_column, $context = n $this->meta_id_column = sanitize_key( $type . '_id' ); $this->object_type = $type; + // Extract object subtypes from the parent query context. + $this->object_subtypes = array(); + if ( 'post' === $type && $context instanceof WP_Query && ! empty( $context->query_vars['post_type'] ) ) { + $post_type = $context->query_vars['post_type']; + if ( is_array( $post_type ) ) { + $this->object_subtypes = $post_type; + } elseif ( 'any' !== $post_type ) { + $this->object_subtypes = array( $post_type ); + } + } + $this->primary_table = $primary_table; $this->primary_id_column = $primary_id_column; @@ -546,7 +565,7 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) if ( $this->object_type && isset( $clause['key'] ) && is_string( $clause['key'] ) - && ! wp_meta_key_invalidates_query_cache( $this->object_type, $clause['key'] ) + && $this->is_non_cacheable_meta_key( $clause['key'] ) ) { _doing_it_wrong( __METHOD__, @@ -838,6 +857,33 @@ public function get_clauses() { return $this->clauses; } + /** + * Checks whether a meta key is registered as non-query-cacheable. + * + * When object subtypes are known (e.g. post types from the parent WP_Query), + * checks each subtype individually. If any subtype has the key registered as + * non-cacheable, returns true. Falls back to searching across all subtypes + * when no subtypes are available. + * + * @since 7.0.0 + * + * @param string $meta_key Meta key to check. + * @return bool True if the meta key is non-cacheable, false otherwise. + */ + protected function is_non_cacheable_meta_key( $meta_key ) { + if ( ! empty( $this->object_subtypes ) ) { + foreach ( $this->object_subtypes as $subtype ) { + if ( ! wp_meta_key_invalidates_query_cache( $this->object_type, $meta_key, $subtype ) ) { + return true; + } + } + return false; + } + + // No subtypes available — fall back to checking all subtypes. + return ! wp_meta_key_invalidates_query_cache( $this->object_type, $meta_key ); + } + /** * Identifies an existing table alias that is compatible with the current * query clause. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2344cda92c7b2..9d00f74ce5ebf 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8470,8 +8470,11 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) { * @param string $meta_key Optional. Meta key. Passed by meta action hooks. */ function wp_cache_set_posts_last_changed( $meta_id = 0, $object_id = 0, $meta_key = '' ) { - if ( $meta_key && ! wp_meta_key_invalidates_query_cache( 'post', $meta_key ) ) { - return; + if ( $meta_key ) { + $post_type = $object_id ? get_post_type( $object_id ) : ''; + if ( ! wp_meta_key_invalidates_query_cache( 'post', $meta_key, $post_type ? $post_type : '' ) ) { + return; + } } wp_cache_set_last_changed( 'posts' ); } diff --git a/tests/phpunit/tests/meta/invalidatesQueryCache.php b/tests/phpunit/tests/meta/invalidatesQueryCache.php index c0ffaa8f303c6..e5db6c4d49ff0 100644 --- a/tests/phpunit/tests/meta/invalidatesQueryCache.php +++ b/tests/phpunit/tests/meta/invalidatesQueryCache.php @@ -22,6 +22,7 @@ public static function wpTearDownAfterClass() { public function tear_down() { unregister_meta_key( 'post', 'nocache_meta' ); unregister_meta_key( 'post', 'nocache_meta', 'post' ); + unregister_meta_key( 'post', 'nocache_meta', 'page' ); unregister_meta_key( 'post', 'normal_meta' ); parent::tear_down(); } @@ -216,17 +217,21 @@ public function test_subtype_registration_skips_cache_invalidation() { } /** - * A key registered for a specific post type should be refused in meta queries. + * A key registered for a specific post type should be refused in meta queries + * when the WP_Query targets that post type. * * @expectedIncorrectUsage WP_Meta_Query::get_sql_for_clause */ - public function test_subtype_registration_refuses_meta_query() { + public function test_subtype_registration_refuses_meta_query_for_matching_post_type() { register_post_meta( 'post', 'nocache_meta', array( 'invalidates_query_cache' => false ) ); + $query = new WP_Query(); + $query->query_vars['post_type'] = 'post'; + $meta_query = new WP_Meta_Query( array( array( @@ -236,8 +241,88 @@ public function test_subtype_registration_refuses_meta_query() { ) ); - $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' ); + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID', $query ); - $this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should not appear in WHERE clause.' ); + $this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should not appear in WHERE clause for matching post type.' ); + } + + /** + * A key registered as non-cacheable for one post type should be allowed + * in meta queries targeting a different post type. + */ + public function test_subtype_registration_allows_meta_query_for_different_post_type() { + register_post_meta( + 'page', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + $query = new WP_Query(); + $query->query_vars['post_type'] = 'post'; + + $meta_query = new WP_Meta_Query( + array( + array( + 'key' => 'nocache_meta', + 'value' => 'test', + ), + ) + ); + + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID', $query ); + + $this->assertStringContainsString( 'nocache_meta', $sql['where'], 'Meta key should be allowed for a post type where it is cacheable.' ); + } + + /** + * When querying multiple post types, if the key is non-cacheable on any + * of them, the clause should be refused. + * + * @expectedIncorrectUsage WP_Meta_Query::get_sql_for_clause + */ + public function test_subtype_registration_refuses_meta_query_for_array_with_matching_post_type() { + register_post_meta( + 'page', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + $query = new WP_Query(); + $query->query_vars['post_type'] = array( 'post', 'page' ); + + $meta_query = new WP_Meta_Query( + array( + array( + 'key' => 'nocache_meta', + 'value' => 'test', + ), + ) + ); + + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID', $query ); + + $this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should be refused when any queried post type has it as non-cacheable.' ); + } + + /** + * A key registered for a specific post type should not skip cache invalidation + * when written to a different post type. + */ + public function test_subtype_registration_does_not_skip_cache_for_different_post_type() { + register_post_meta( + 'page', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + // self::$post_id is a 'post', not a 'page'. + wp_cache_set_last_changed( 'posts' ); + $before = wp_cache_get_last_changed( 'posts' ); + + usleep( 1000 ); + add_post_meta( self::$post_id, 'nocache_meta', 'value1' ); + + $after = wp_cache_get_last_changed( 'posts' ); + $this->assertNotSame( $before, $after, 'last_changed should still change when writing to a post type where the key is cacheable.' ); } } From e98e1605d3b2e50ab11c4cb6c46013cdc0613c48 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 18 Mar 2026 17:31:05 +0100 Subject: [PATCH 06/11] Restrict invalidates_query_cache to post meta, add reasoning comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename wp_meta_key_invalidates_query_cache to wp_post_meta_invalidates_query_cache with a required post_type parameter. Reject the flag for non-post object types in register_meta. In WP_Meta_Query, only refuse non-cacheable keys when the post type is known from the parent WP_Query context. Without context, allow the query through — it is better to allow a query than to incorrectly block a legitimate one. Cache correctness is guaranteed by the invalidation side (wp_cache_set_posts_last_changed), which always knows the post type from the object ID. Add comments explaining this reasoning throughout the code and tests. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-meta-query.php | 39 ++++++++----- src/wp-includes/meta.php | 43 ++++++-------- src/wp-includes/post.php | 11 +++- .../tests/meta/invalidatesQueryCache.php | 58 +++++++++++++++++-- 4 files changed, 104 insertions(+), 47 deletions(-) diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index a22fde3c70489..2b57445da343a 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -561,8 +561,8 @@ protected function get_sql_for_query( &$query, $depth = 0 ) { public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) { global $wpdb; - // Refuse to query by a meta key registered as non-query-cacheable. - if ( $this->object_type + // Refuse to query by a post meta key registered as non-query-cacheable. + if ( 'post' === $this->object_type && isset( $clause['key'] ) && is_string( $clause['key'] ) && $this->is_non_cacheable_meta_key( $clause['key'] ) @@ -860,10 +860,21 @@ public function get_clauses() { /** * Checks whether a meta key is registered as non-query-cacheable. * - * When object subtypes are known (e.g. post types from the parent WP_Query), - * checks each subtype individually. If any subtype has the key registered as - * non-cacheable, returns true. Falls back to searching across all subtypes - * when no subtypes are available. + * This check is deliberately conservative: it only refuses a meta key + * when the post type is known from the parent WP_Query context. When + * no post type context is available (e.g. direct WP_Meta_Query usage + * without a WP_Query, or post_type set to 'any'), the key is allowed + * through. This avoids incorrectly blocking legitimate queries. + * + * Correctness is guaranteed by the cache invalidation side + * (wp_cache_set_posts_last_changed), which always has the post type + * from the object ID. That side is authoritative — it ensures + * non-cacheable meta never invalidates query caches. This query-side + * check is a safety net to warn developers and prevent obviously + * broken queries, not the primary enforcement mechanism. + * + * When multiple post types are queried, the key is refused if any + * of them has it registered as non-cacheable. * * @since 7.0.0 * @@ -871,17 +882,17 @@ public function get_clauses() { * @return bool True if the meta key is non-cacheable, false otherwise. */ protected function is_non_cacheable_meta_key( $meta_key ) { - if ( ! empty( $this->object_subtypes ) ) { - foreach ( $this->object_subtypes as $subtype ) { - if ( ! wp_meta_key_invalidates_query_cache( $this->object_type, $meta_key, $subtype ) ) { - return true; - } - } + if ( empty( $this->object_subtypes ) ) { return false; } - // No subtypes available — fall back to checking all subtypes. - return ! wp_meta_key_invalidates_query_cache( $this->object_type, $meta_key ); + foreach ( $this->object_subtypes as $subtype ) { + if ( ! wp_post_meta_invalidates_query_cache( $meta_key, $subtype ) ) { + return true; + } + } + + return false; } /** diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index acea0c0f5ae75..ec687568469af 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -1509,6 +1509,12 @@ function register_meta( $object_type, $meta_key, $args, $deprecated = null ) { } } + if ( ! $args['invalidates_query_cache'] && 'post' !== $object_type ) { + _doing_it_wrong( __FUNCTION__, __( 'The invalidates_query_cache parameter is only supported for post meta.' ), '7.0.0' ); + + return false; + } + // If `auth_callback` is not provided, fall back to `is_protected_meta()`. if ( empty( $args['auth_callback'] ) ) { if ( is_protected_meta( $meta_key, $object_type ) ) { @@ -1646,47 +1652,34 @@ function registered_meta_key_exists( $object_type, $meta_key, $object_subtype = } /** - * Checks whether a meta key invalidates query caches when updated. + * Checks whether a post meta key invalidates query caches when updated. * - * Meta keys registered with `'invalidates_query_cache' => false` will not + * Post meta keys registered with `'invalidates_query_cache' => false` will not * cause query cache invalidation when added, updated, or deleted. This is - * useful for high-frequency meta that is never used in query filters, such + * useful for high-frequency post meta that is never used in query filters, such * as real-time collaboration sync data. * * Unregistered meta keys are assumed to invalidate query caches. * * @since 7.0.0 * - * @param string $object_type Type of object metadata is for. Accepts 'post', 'comment', 'term', 'user', - * or any other object type with an associated meta table. - * @param string $meta_key Metadata key. - * @param string $object_subtype Optional. The subtype of the object type. Default empty string. + * @param string $meta_key Metadata key. + * @param string $post_type Post type to check. * @return bool True if the meta key invalidates query caches, false otherwise. */ -function wp_meta_key_invalidates_query_cache( $object_type, $meta_key, $object_subtype = '' ) { - global $wp_meta_keys; - - $meta_keys = get_registered_meta_keys( $object_type, $object_subtype ); +function wp_post_meta_invalidates_query_cache( $meta_key, $post_type ) { + // Check keys registered for this specific post type. + $meta_keys = get_registered_meta_keys( 'post', $post_type ); if ( isset( $meta_keys[ $meta_key ] ) ) { return $meta_keys[ $meta_key ]['invalidates_query_cache']; } - // Also check keys registered without a subtype. - if ( '' !== $object_subtype ) { - $meta_keys = get_registered_meta_keys( $object_type ); - if ( isset( $meta_keys[ $meta_key ] ) ) { - return $meta_keys[ $meta_key ]['invalidates_query_cache']; - } - } + // Also check keys registered for all post types. + $meta_keys = get_registered_meta_keys( 'post' ); - // When no subtype is given, search across all registered subtypes. - if ( '' === $object_subtype && is_array( $wp_meta_keys ) && isset( $wp_meta_keys[ $object_type ] ) ) { - foreach ( $wp_meta_keys[ $object_type ] as $registered_keys ) { - if ( isset( $registered_keys[ $meta_key ] ) ) { - return $registered_keys[ $meta_key ]['invalidates_query_cache']; - } - } + if ( isset( $meta_keys[ $meta_key ] ) ) { + return $meta_keys[ $meta_key ]['invalidates_query_cache']; } // Unregistered keys default to invalidating caches. diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 9d00f74ce5ebf..63269238a1971 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -8462,6 +8462,11 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) { * the cache is not invalidated. This allows high-frequency meta writes (e.g. * real-time collaboration data) to avoid invalidating query caches site-wide. * + * This is the authoritative check — meta actions always provide both the + * object ID and meta key, so the exact post type is always known. This + * guarantees that non-cacheable meta never incorrectly invalidates caches, + * regardless of how the meta key was registered (globally or per post type). + * * @since 5.0.0 * @since 7.0.0 Added the `$meta_id`, `$object_id`, and `$meta_key` parameters. * @@ -8470,9 +8475,9 @@ function wp_add_trashed_suffix_to_post_name_for_post( $post ) { * @param string $meta_key Optional. Meta key. Passed by meta action hooks. */ function wp_cache_set_posts_last_changed( $meta_id = 0, $object_id = 0, $meta_key = '' ) { - if ( $meta_key ) { - $post_type = $object_id ? get_post_type( $object_id ) : ''; - if ( ! wp_meta_key_invalidates_query_cache( 'post', $meta_key, $post_type ? $post_type : '' ) ) { + if ( $meta_key && $object_id ) { + $post_type = get_post_type( $object_id ); + if ( $post_type && ! wp_post_meta_invalidates_query_cache( $meta_key, $post_type ) ) { return; } } diff --git a/tests/phpunit/tests/meta/invalidatesQueryCache.php b/tests/phpunit/tests/meta/invalidatesQueryCache.php index e5db6c4d49ff0..7f38389eb16bf 100644 --- a/tests/phpunit/tests/meta/invalidatesQueryCache.php +++ b/tests/phpunit/tests/meta/invalidatesQueryCache.php @@ -2,6 +2,18 @@ /** * Tests for the `invalidates_query_cache` parameter of `register_meta()`. * + * Cache correctness relies on two complementary mechanisms: + * + * 1. Cache invalidation (wp_cache_set_posts_last_changed): the authoritative + * check. Meta write hooks always provide the object ID, so the post type + * is always known. This guarantees non-cacheable meta never incorrectly + * bumps the 'posts' last_changed timestamp. + * + * 2. Meta query blocking (WP_Meta_Query): a conservative safety net. It only + * refuses a non-cacheable key when the post type is known from the parent + * WP_Query context. Without context, the key is allowed through — it's + * better to allow a query than to incorrectly block a legitimate one. + * * @group meta * @group cache * @@ -152,7 +164,8 @@ public function test_registered_cacheable_meta_still_invalidates_cache() { } /** - * WP_Meta_Query should refuse to query by a non-cacheable meta key. + * WP_Meta_Query should refuse to query by a non-cacheable meta key + * when the WP_Query context provides a post type. * * @expectedIncorrectUsage WP_Meta_Query::get_sql_for_clause */ @@ -163,6 +176,9 @@ public function test_meta_query_refuses_non_cacheable_key() { array( 'invalidates_query_cache' => false ) ); + $query = new WP_Query(); + $query->query_vars['post_type'] = 'post'; + $meta_query = new WP_Meta_Query( array( array( @@ -172,12 +188,39 @@ public function test_meta_query_refuses_non_cacheable_key() { ) ); - $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' ); + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID', $query ); $this->assertStringNotContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should not appear in WHERE clause.' ); $this->assertStringNotContainsString( 'nocache_meta', $sql['join'], 'Non-cacheable meta key should not appear in JOIN clause.' ); } + /** + * WP_Meta_Query should allow a non-cacheable meta key when no WP_Query + * context is available. Without knowing the post type, refusing the query + * could incorrectly block legitimate usage. Cache correctness is still + * guaranteed by the invalidation side, which always knows the post type. + */ + public function test_meta_query_allows_non_cacheable_key_without_context() { + register_post_meta( + '', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + $meta_query = new WP_Meta_Query( + array( + array( + 'key' => 'nocache_meta', + 'value' => 'test', + ), + ) + ); + + $sql = $meta_query->get_sql( 'post', 'wp_posts', 'ID' ); + + $this->assertStringContainsString( 'nocache_meta', $sql['where'], 'Non-cacheable meta key should be allowed without WP_Query context.' ); + } + /** * WP_Meta_Query should work normally for regular meta keys. */ @@ -248,7 +291,10 @@ public function test_subtype_registration_refuses_meta_query_for_matching_post_t /** * A key registered as non-cacheable for one post type should be allowed - * in meta queries targeting a different post type. + * in meta queries targeting a different post type. The query-side check + * is conservative — it only refuses when the post type is known to be + * non-cacheable. Cache correctness for the other post type is enforced + * by the invalidation side. */ public function test_subtype_registration_allows_meta_query_for_different_post_type() { register_post_meta( @@ -305,8 +351,10 @@ public function test_subtype_registration_refuses_meta_query_for_array_with_matc } /** - * A key registered for a specific post type should not skip cache invalidation - * when written to a different post type. + * A key registered as non-cacheable for one post type should still + * invalidate the cache when written to a different post type. The + * invalidation side always knows the exact post type from the object ID, + * so per-post-type behavior is always correct. */ public function test_subtype_registration_does_not_skip_cache_for_different_post_type() { register_post_meta( From c0a7b55745c0d34c7b9d49f79f8bfa2664d5404e Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 18 Mar 2026 18:30:04 +0100 Subject: [PATCH 07/11] Fix coding standards: align equals signs in test file Co-Authored-By: Claude Opus 4.6 --- tests/phpunit/tests/meta/invalidatesQueryCache.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/meta/invalidatesQueryCache.php b/tests/phpunit/tests/meta/invalidatesQueryCache.php index 7f38389eb16bf..a41ffc935ab84 100644 --- a/tests/phpunit/tests/meta/invalidatesQueryCache.php +++ b/tests/phpunit/tests/meta/invalidatesQueryCache.php @@ -176,7 +176,7 @@ public function test_meta_query_refuses_non_cacheable_key() { array( 'invalidates_query_cache' => false ) ); - $query = new WP_Query(); + $query = new WP_Query(); $query->query_vars['post_type'] = 'post'; $meta_query = new WP_Meta_Query( @@ -272,7 +272,7 @@ public function test_subtype_registration_refuses_meta_query_for_matching_post_t array( 'invalidates_query_cache' => false ) ); - $query = new WP_Query(); + $query = new WP_Query(); $query->query_vars['post_type'] = 'post'; $meta_query = new WP_Meta_Query( @@ -303,7 +303,7 @@ public function test_subtype_registration_allows_meta_query_for_different_post_t array( 'invalidates_query_cache' => false ) ); - $query = new WP_Query(); + $query = new WP_Query(); $query->query_vars['post_type'] = 'post'; $meta_query = new WP_Meta_Query( @@ -333,7 +333,7 @@ public function test_subtype_registration_refuses_meta_query_for_array_with_matc array( 'invalidates_query_cache' => false ) ); - $query = new WP_Query(); + $query = new WP_Query(); $query->query_vars['post_type'] = array( 'post', 'page' ); $meta_query = new WP_Meta_Query( From 314106c31515ed98138703b16d3ecf41d81bd713 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 19 Mar 2026 08:20:50 +0100 Subject: [PATCH 08/11] Exclude non-cacheable meta keys from keyless meta_value queries When a WP_Query uses meta_value without meta_key, the query matches across all meta keys. Non-cacheable meta keys must be excluded from these results, otherwise stale cached results would be served since cache invalidation is skipped for those keys. Adds a NOT IN clause to exclude registered non-cacheable keys when a meta query clause has a value but no key. Adds tests based on the cases raised in PR review. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-meta-query.php | 42 ++++++++++++++ .../tests/meta/invalidatesQueryCache.php | 56 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index 2b57445da343a..7649293306177 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -776,6 +776,21 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) } } + // When querying by meta_value without a specific key, exclude + // non-cacheable meta keys so their rows never appear in results. + // This is necessary because cache invalidation is skipped for these + // keys, so including them would produce stale cached query results. + if ( 'post' === $this->object_type + && ! array_key_exists( 'key', $clause ) + && array_key_exists( 'value', $clause ) + ) { + $excluded_keys = $this->get_non_cacheable_meta_keys(); + if ( ! empty( $excluded_keys ) ) { + $placeholders = implode( ',', array_fill( 0, count( $excluded_keys ), '%s' ) ); + $sql_chunks['where'][] = $wpdb->prepare( "$alias.meta_key NOT IN ($placeholders)", $excluded_keys ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + } + // meta_value. if ( array_key_exists( 'value', $clause ) ) { $meta_value = $clause['value']; @@ -895,6 +910,33 @@ protected function is_non_cacheable_meta_key( $meta_key ) { return false; } + /** + * Returns all post meta keys registered with invalidates_query_cache false. + * + * @since 7.0.0 + * + * @return string[] Array of non-cacheable meta keys. + */ + protected function get_non_cacheable_meta_keys() { + global $wp_meta_keys; + + $keys = array(); + + if ( ! is_array( $wp_meta_keys ) || ! isset( $wp_meta_keys['post'] ) ) { + return $keys; + } + + foreach ( $wp_meta_keys['post'] as $registered_keys ) { + foreach ( $registered_keys as $meta_key => $args ) { + if ( isset( $args['invalidates_query_cache'] ) && ! $args['invalidates_query_cache'] ) { + $keys[] = $meta_key; + } + } + } + + return array_unique( $keys ); + } + /** * Identifies an existing table alias that is compatible with the current * query clause. diff --git a/tests/phpunit/tests/meta/invalidatesQueryCache.php b/tests/phpunit/tests/meta/invalidatesQueryCache.php index a41ffc935ab84..7a51c576543c8 100644 --- a/tests/phpunit/tests/meta/invalidatesQueryCache.php +++ b/tests/phpunit/tests/meta/invalidatesQueryCache.php @@ -373,4 +373,60 @@ public function test_subtype_registration_does_not_skip_cache_for_different_post $after = wp_cache_get_last_changed( 'posts' ); $this->assertNotSame( $before, $after, 'last_changed should still change when writing to a post type where the key is cacheable.' ); } + + /** + * A WP_Query using meta_value without meta_key should not return posts + * matched via a non-cacheable meta key. + */ + public function test_wp_query_meta_value_excludes_non_cacheable_keys() { + register_post_meta( + 'post', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + add_post_meta( self::$post_id, 'nocache_meta', 'nocache_value' ); + + $query = new WP_Query( + array( + 'fields' => 'ids', + 'meta_value' => 'nocache_value', + ) + ); + + $this->assertEmpty( $query->posts, 'WP_Query by meta_value should not match non-cacheable meta keys.' ); + } + + /** + * A WP_Query using meta_value without meta_key should not return stale + * cached results after non-cacheable meta is deleted. + */ + public function test_wp_query_meta_value_no_stale_cache_after_delete() { + register_post_meta( + 'post', + 'nocache_meta', + array( 'invalidates_query_cache' => false ) + ); + + add_post_meta( self::$post_id, 'nocache_meta', 'nocache_value' ); + + // Run the query once to prime the cache. + $query1 = new WP_Query( + array( + 'fields' => 'ids', + 'meta_value' => 'nocache_value', + ) + ); + + delete_post_meta( self::$post_id, 'nocache_meta' ); + + $query2 = new WP_Query( + array( + 'fields' => 'ids', + 'meta_value' => 'nocache_value', + ) + ); + + $this->assertEmpty( $query2->posts, 'WP_Query should not return stale cached results for non-cacheable meta.' ); + } } From 3189f2996cbe2ad7220213a609ed262a030cc8b8 Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 19 Mar 2026 09:27:51 +0100 Subject: [PATCH 09/11] Tests: Update registerMeta test expectations for invalidates_query_cache Add the new invalidates_query_cache default (true) to existing test assertions that check the full shape of registered meta key args. Co-Authored-By: Claude Opus 4.6 --- tests/phpunit/tests/meta/registerMeta.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/meta/registerMeta.php b/tests/phpunit/tests/meta/registerMeta.php index 30b6920bdea0d..81aedf38e72d2 100644 --- a/tests/phpunit/tests/meta/registerMeta.php +++ b/tests/phpunit/tests/meta/registerMeta.php @@ -98,7 +98,8 @@ public function test_register_meta_with_post_object_type_populates_wp_meta_keys( 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, - 'revisions_enabled' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -124,7 +125,8 @@ public function test_register_meta_with_term_object_type_populates_wp_meta_keys( 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, - 'revisions_enabled' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -180,7 +182,8 @@ public function test_register_meta_with_current_sanitize_callback_populates_wp_m 'sanitize_callback' => array( $this, '_new_sanitize_meta_cb' ), 'auth_callback' => '__return_true', 'show_in_rest' => false, - 'revisions_enabled' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -362,7 +365,8 @@ public function test_register_meta_with_subtype_populates_wp_meta_keys( $type, $ 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, - 'revisions_enabled' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -417,7 +421,8 @@ public function test_unregister_meta_without_subtype_keeps_subtype_meta_key( $ty 'sanitize_callback' => null, 'auth_callback' => '__return_true', 'show_in_rest' => false, - 'revisions_enabled' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), From 55359d8b2a3599399cc6c82f53fea25e602fe5ba Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 19 Mar 2026 09:50:20 +0100 Subject: [PATCH 10/11] Tests: Update user meta test expectation for invalidates_query_cache Co-Authored-By: Claude Opus 4.6 --- .../phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php b/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php index a45b015ad9728..689ea9b40dcdb 100644 --- a/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php +++ b/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php @@ -53,7 +53,8 @@ public function test_should_register_persisted_preferences_meta() { 'additionalProperties' => true, ), ), - 'revisions_enabled' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), $wp_meta_keys['user'][''][ $meta_key ], 'The registered metadata did not have the expected structure' From 97ac27dac777f35c6a20085bb59cc93ab81a382d Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 19 Mar 2026 09:58:01 +0100 Subject: [PATCH 11/11] Fix coding standards: align array double arrows in test expectations Co-Authored-By: Claude Opus 4.6 --- tests/phpunit/tests/meta/registerMeta.php | 90 +++++++++---------- .../wpRegisterPersistedPreferencesMeta.php | 18 ++-- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/tests/phpunit/tests/meta/registerMeta.php b/tests/phpunit/tests/meta/registerMeta.php index 81aedf38e72d2..62dbe982ef491 100644 --- a/tests/phpunit/tests/meta/registerMeta.php +++ b/tests/phpunit/tests/meta/registerMeta.php @@ -91,15 +91,15 @@ public function test_register_meta_with_post_object_type_populates_wp_meta_keys( 'post' => array( '' => array( 'flight_number' => array( - 'type' => 'string', - 'label' => '', - 'description' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => '__return_true', - 'show_in_rest' => false, - 'revisions_enabled' => false, - 'invalidates_query_cache' => true, + 'type' => 'string', + 'label' => '', + 'description' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => '__return_true', + 'show_in_rest' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -118,15 +118,15 @@ public function test_register_meta_with_term_object_type_populates_wp_meta_keys( 'term' => array( '' => array( 'category_icon' => array( - 'type' => 'string', - 'label' => '', - 'description' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => '__return_true', - 'show_in_rest' => false, - 'revisions_enabled' => false, - 'invalidates_query_cache' => true, + 'type' => 'string', + 'label' => '', + 'description' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => '__return_true', + 'show_in_rest' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -175,15 +175,15 @@ public function test_register_meta_with_current_sanitize_callback_populates_wp_m 'post' => array( '' => array( 'flight_number' => array( - 'type' => 'string', - 'label' => '', - 'description' => '', - 'single' => false, - 'sanitize_callback' => array( $this, '_new_sanitize_meta_cb' ), - 'auth_callback' => '__return_true', - 'show_in_rest' => false, - 'revisions_enabled' => false, - 'invalidates_query_cache' => true, + 'type' => 'string', + 'label' => '', + 'description' => '', + 'single' => false, + 'sanitize_callback' => array( $this, '_new_sanitize_meta_cb' ), + 'auth_callback' => '__return_true', + 'show_in_rest' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -358,15 +358,15 @@ public function test_register_meta_with_subtype_populates_wp_meta_keys( $type, $ $type => array( $subtype => array( 'flight_number' => array( - 'type' => 'string', - 'label' => '', - 'description' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => '__return_true', - 'show_in_rest' => false, - 'revisions_enabled' => false, - 'invalidates_query_cache' => true, + 'type' => 'string', + 'label' => '', + 'description' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => '__return_true', + 'show_in_rest' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), @@ -414,15 +414,15 @@ public function test_unregister_meta_without_subtype_keeps_subtype_meta_key( $ty $type => array( $subtype => array( 'flight_number' => array( - 'type' => 'string', - 'label' => '', - 'description' => '', - 'single' => false, - 'sanitize_callback' => null, - 'auth_callback' => '__return_true', - 'show_in_rest' => false, - 'revisions_enabled' => false, - 'invalidates_query_cache' => true, + 'type' => 'string', + 'label' => '', + 'description' => '', + 'single' => false, + 'sanitize_callback' => null, + 'auth_callback' => '__return_true', + 'show_in_rest' => false, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), ), ), diff --git a/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php b/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php index 689ea9b40dcdb..79a07ce7a244e 100644 --- a/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php +++ b/tests/phpunit/tests/user/wpRegisterPersistedPreferencesMeta.php @@ -30,13 +30,13 @@ public function test_should_register_persisted_preferences_meta() { // Test to detect changes in meta key structure. $this->assertSame( array( - 'type' => 'object', - 'label' => '', - 'description' => '', - 'single' => true, - 'sanitize_callback' => null, - 'auth_callback' => '__return_true', - 'show_in_rest' => array( + 'type' => 'object', + 'label' => '', + 'description' => '', + 'single' => true, + 'sanitize_callback' => null, + 'auth_callback' => '__return_true', + 'show_in_rest' => array( 'name' => 'persisted_preferences', 'type' => 'object', 'schema' => array( @@ -53,8 +53,8 @@ public function test_should_register_persisted_preferences_meta() { 'additionalProperties' => true, ), ), - 'revisions_enabled' => false, - 'invalidates_query_cache' => true, + 'revisions_enabled' => false, + 'invalidates_query_cache' => true, ), $wp_meta_keys['user'][''][ $meta_key ], 'The registered metadata did not have the expected structure'