From 4f963f9d2bf5cafebef0822534913733b9e1d4a7 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 19 Feb 2026 00:23:19 -0500 Subject: [PATCH 1/2] Updates from backport PR --- .../class-wp-http-polling-sync-server.php | 133 ++++++++---- .../class-wp-sync-post-meta-storage.php | 189 ++++++++++-------- lib/compat/wordpress-7.0/collaboration.php | 2 +- .../interface-wp-sync-storage.php | 20 +- 4 files changed, 204 insertions(+), 140 deletions(-) diff --git a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php index f97c287bc7ea0b..d9bd63bc7429d7 100644 --- a/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php @@ -29,7 +29,7 @@ class WP_HTTP_Polling_Sync_Server { * @since 7.0.0 * @var int */ - const AWARENESS_TIMEOUT_IN_S = 30; + const AWARENESS_TIMEOUT = 30; /** * Threshold used to signal clients to send a compaction update. @@ -75,9 +75,8 @@ class WP_HTTP_Polling_Sync_Server { * Storage backend for sync updates. * * @since 7.0.0 - * @var WP_Sync_Storage */ - private $storage; + private WP_Sync_Storage $storage; /** * Constructor. @@ -133,9 +132,9 @@ public function register_routes(): void { 'type' => 'integer', ), 'room' => array( - 'sanitize_callback' => 'sanitize_text_field', - 'required' => true, - 'type' => 'string', + 'required' => true, + 'type' => 'string', + 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$', ), 'updates' => array( 'items' => $typed_update_args, @@ -178,9 +177,9 @@ public function check_permissions( WP_REST_Request $request ) { // Minimum cap check. Is user logged in with a contributor role or higher? if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( - 'forbidden', + 'rest_cannot_edit', __( 'You do not have permission to perform this action', 'gutenberg' ), - array( 'status' => 401 ) + array( 'status' => rest_authorization_required_code() ) ); } @@ -191,27 +190,19 @@ public function check_permissions( WP_REST_Request $request ) { $type_parts = explode( '/', $room, 2 ); $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); - if ( 2 !== count( $type_parts ) ) { - return new WP_Error( - 'invalid_room_format', - __( 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id', 'gutenberg' ), - array( 'status' => 400 ) - ); - } - $entity_kind = $type_parts[0]; $entity_name = $object_parts[0]; $object_id = $object_parts[1] ?? null; if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( - 'forbidden', + 'rest_cannot_edit', sprintf( /* translators: %s: The room name encodes the current entity being synced. */ __( 'You do not have permission to sync this entity: %s.', 'gutenberg' ), $room ), - array( 'status' => 401 ) + array( 'status' => rest_authorization_required_code() ) ); } } @@ -244,11 +235,17 @@ public function handle_request( WP_REST_Request $request ) { $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); // The lowest client ID is nominated to perform compaction when needed. - $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + $is_compactor = false; + if ( count( $merged_awareness ) > 0 ) { + $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; + } // Process each update according to its type. foreach ( $room_request['updates'] as $update ) { - $this->process_sync_update( $room, $client_id, $cursor, $update ); + $result = $this->process_sync_update( $room, $client_id, $cursor, $update ); + if ( is_wp_error( $result ) ) { + return $result; + } } // Get updates for this client. @@ -264,15 +261,17 @@ public function handle_request( WP_REST_Request $request ) { /** * Checks if the current user can sync a specific entity type. * - * @param string $entity_kind The entity kind. - * @param string $entity_name The entity name. - * @param string|null $object_id The object ID (if applicable). + * @since 7.0.0 + * + * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'. + * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'. + * @param string|null $object_id The object ID / entity key for single entities, null for collections. * @return bool True if user has permission, otherwise false. */ private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { - // Handle post type entities. + // Handle single post type entities with a defined object ID. if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { - return current_user_can( 'edit_post', absint( $object_id ) ); + return current_user_can( 'edit_post', (int) $object_id ); } // Handle single taxonomy term entities with a defined object ID. @@ -281,17 +280,27 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); } - // Handle root comment entities + // Handle single comment entities with a defined object ID. if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) { return current_user_can( 'edit_comment', (int) $object_id ); } - // All of the remaining checks are for collections. If an object ID is - // provided, reject the request. + // All the remaining checks are for collections. If an object ID is provided, + // reject the request. if ( null !== $object_id ) { return false; } + // For postType collections, check if the user can edit posts of this type. + if ( 'postType' === $entity_kind ) { + $post_type_object = get_post_type_object( $entity_name ); + if ( ! isset( $post_type_object->cap->edit_posts ) ) { + return false; + } + + return current_user_can( $post_type_object->cap->edit_posts ); + } + // Collection syncing does not exchange entity data. It only signals if // another user has updated an entity in the collection. Therefore, we only // compare against an allow list of collection types. @@ -307,10 +316,12 @@ private function can_user_sync_entity_type( string $entity_kind, string $entity_ /** * Processes and stores an awareness update from a client. * + * @since 7.0.0 + * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param array|null $awareness_update Awareness state sent by the client. - * @return array> Updated awareness state for the room. + * @return array> Map of client ID to awareness state. */ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { $existing_awareness = $this->storage->get_awareness_state( $room ); @@ -324,7 +335,7 @@ private function process_awareness_update( string $room, int $client_id, ?array } // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) { + if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) { continue; } @@ -340,12 +351,13 @@ private function process_awareness_update( string $room, int $client_id, ?array ); } + // This action can fail, but it shouldn't fail the entire request. $this->storage->set_awareness_state( $room, $updated_awareness ); // Convert to client_id => state map for response. $response = array(); foreach ( $updated_awareness as $entry ) { - $response[ $entry['client_id'] ] = (object) $entry['state']; + $response[ $entry['client_id'] ] = $entry['state']; } return $response; @@ -354,12 +366,15 @@ private function process_awareness_update( string $room, int $client_id, ?array /** * Processes a sync update based on its type. * - * @param string $room Room identifier. - * @param int $client_id Client identifier. - * @param int $cursor Client cursor (marker of last seen update). - * @param array $update Sync update with 'type' and 'data' fields. + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $client_id Client identifier. + * @param int $cursor Client cursor (marker of last seen update). + * @param array{data: string, type: string} $update Sync update. + * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { + private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) { $data = $update['data']; $type = $update['type']; @@ -384,8 +399,15 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } if ( ! $has_newer_compaction ) { - $this->storage->remove_updates_before_cursor( $room, $cursor ); - $this->add_update( $room, $client_id, $type, $data ); + if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) { + return new WP_Error( + 'rest_sync_storage_error', + __( 'Failed to remove updates during compaction.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + return $this->add_update( $room, $client_id, $type, $data ); } break; @@ -401,27 +423,43 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * * All updates are stored persistently. */ - $this->add_update( $room, $client_id, $type, $data ); - break; + return $this->add_update( $room, $client_id, $type, $data ); } + + return new WP_Error( + 'rest_invalid_update_type', + __( 'Invalid sync update type.', 'gutenberg' ), + array( 'status' => 400 ) + ); } /** * Adds an update to a room's update list via storage. * + * @since 7.0.0 + * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param string $type Update type (sync_step1, sync_step2, update, compaction). * @param string $data Base64-encoded update data. + * @return true|WP_Error True on success, WP_Error on storage failure. */ - private function add_update( string $room, int $client_id, string $type, string $data ): void { + private function add_update( string $room, int $client_id, string $type, string $data ) { $update = array( 'client_id' => $client_id, 'data' => $data, 'type' => $type, ); - $this->storage->add_update( $room, $update ); + if ( ! $this->storage->add_update( $room, $update ) ) { + return new WP_Error( + 'rest_sync_storage_error', + __( 'Failed to store sync update.', 'gutenberg' ), + array( 'status' => 500 ) + ); + } + + return true; } /** @@ -430,11 +468,19 @@ private function add_update( string $room, int $client_id, string $type, string * Delegates cursor-based retrieval to the storage layer, then applies * client-specific filtering and compaction logic. * + * @since 7.0.0 + * * @param string $room Room identifier. * @param int $client_id Client identifier. * @param int $cursor Return updates after this cursor. * @param bool $is_compactor True if this client is nominated to perform compaction. - * @return array Response data for this room. + * @return array{ + * end_cursor: int, + * should_compact: bool, + * room: string, + * total_updates: int, + * updates: array, + * } Response data for this room. */ private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); @@ -453,7 +499,6 @@ private function get_updates( string $room, int $client_id, int $cursor, bool $i ); } - // Determine if this client should perform compaction. $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD; return array( diff --git a/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php b/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php index 1a6596656ea7e7..d05f41c8469eab 100644 --- a/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php +++ b/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php @@ -11,7 +11,7 @@ * Core class that provides an interface for storing and retrieving sync * updates and awareness data during a collaborative session. * - * Data is stored as post meta on a singleton post of a custom post type. + * Data is stored as post meta on a dedicated post per room of a custom post type. * * @since 7.0.0 * @@ -27,32 +27,44 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { const POST_TYPE = 'wp_sync_storage'; /** - * Cache of cursors by room. + * Meta key for awareness state. * - * @var array + * @since 7.0.0 + * @var string */ - private $room_cursors = array(); + const AWARENESS_META_KEY = 'wp_sync_awareness'; /** - * Cache of update counts by room. + * Meta key for sync updates. * + * @since 7.0.0 + * @var string + */ + const SYNC_UPDATE_META_KEY = 'wp_sync_update'; + + /** + * Cache of cursors by room. + * + * @since 7.0.0 * @var array */ - private $room_update_counts = array(); + private array $room_cursors = array(); /** - * Singleton post ID for storing sync data. + * Cache of update counts by room. * - * @var int|null + * @since 7.0.0 + * @var array */ - private static ?int $storage_post_id = null; + private array $room_update_counts = array(); /** - * Initializer. + * Cache of storage post IDs by room hash. * * @since 7.0.0 + * @var array */ - public function init(): void {} + private static array $storage_post_ids = array(); /** * Adds a sync update to a given room. @@ -61,10 +73,13 @@ public function init(): void {} * * @param string $room Room identifier. * @param mixed $update Sync update. + * @return bool True on success, false on failure. */ - public function add_update( string $room, mixed $update ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); + public function add_update( string $room, $update ): bool { + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return false; + } // Create an envelope and stamp each update to enable cursor-based filtering. $envelope = array( @@ -72,26 +87,39 @@ public function add_update( string $room, mixed $update ): void { 'value' => $update, ); - add_post_meta( $post_id, $meta_key, $envelope, false ); + return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); } /** - * Retrieve all sync updates for a given room. + * Retrieves all sync updates for a given room. + * + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Array of sync updates. + * @return array Sync updates. */ private function get_all_updates( string $room ): array { $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - $updates = get_post_meta( $post_id, $meta_key, false ); + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return array(); + } + + $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); if ( ! is_array( $updates ) ) { $updates = array(); } + // Filter out any updates that don't have the expected structure. + $updates = array_filter( + $updates, + static function ( $update ): bool { + return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); + } + ); + $this->room_update_counts[ $room ] = count( $updates ); return $updates; @@ -106,15 +134,18 @@ private function get_all_updates( string $room ): array { * @return array Awareness state. */ public function get_awareness_state( string $room ): array { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_awareness_meta_key( $room ); - $awareness = get_post_meta( $post_id, $meta_key, true ); + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return array(); + } + + $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true ); if ( ! is_array( $awareness ) ) { return array(); } - return $awareness; + return array_values( $awareness ); } /** @@ -124,22 +155,17 @@ public function get_awareness_state( string $room ): array { * * @param string $room Room identifier. * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. */ - public function set_awareness_state( string $room, array $awareness ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_awareness_meta_key( $room ); - - update_post_meta( $post_id, $meta_key, $awareness ); - } + public function set_awareness_state( string $room, array $awareness ): bool { + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return false; + } - /** - * Gets the meta key for a room's awareness state. - * - * @param string $room Room identifier. - * @return string Meta key. - */ - private function get_awareness_meta_key( string $room ): string { - return 'wp_sync_awareness_' . md5( $room ); + // update_post_meta returns false if the value is the same as the existing value. + update_post_meta( $post_id, self::AWARENESS_META_KEY, $awareness ); + return true; } /** @@ -159,66 +185,67 @@ public function get_cursor( string $room ): int { } /** - * Gets the meta key for a room's updates. + * Gets or creates the storage post for a given room. * - * @param string $room Room identifier. - * @return string Meta key. - */ - private function get_room_meta_key( string $room ): string { - return 'wp_sync_update_' . md5( $room ); - } - - /** - * Gets or creates the singleton post for storing sync data. + * Each room gets its own dedicated post so that post meta cache + * invalidation is scoped to a single room rather than all of them. * + * @since 7.0.0 + * + * @param string $room Room identifier. * @return int|null Post ID. */ - private function get_storage_post_id(): ?int { - if ( is_int( self::$storage_post_id ) ) { - return self::$storage_post_id; + private function get_storage_post_id( string $room ): ?int { + $room_hash = md5( $room ); + + if ( isset( self::$storage_post_ids[ $room_hash ] ) ) { + return self::$storage_post_ids[ $room_hash ]; } - // Try to find existing post. + // Try to find an existing post for this room. $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'posts_per_page' => 1, 'post_status' => 'publish', + 'name' => $room_hash, 'fields' => 'ids', - 'order' => 'ASC', ) ); - // array_first not introduced until WP 6.9 - $post_id = $posts[0] ?? null; + $post_id = array_first( $posts ); if ( is_int( $post_id ) ) { - self::$storage_post_id = $post_id; - return self::$storage_post_id; + self::$storage_post_ids[ $room_hash ] = $post_id; + return $post_id; } - // Create new post if none exists. + // Create new post for this room. $post_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'post_title' => 'Sync Storage', + 'post_name' => $room_hash, ) ); if ( is_int( $post_id ) ) { - self::$storage_post_id = $post_id; + self::$storage_post_ids[ $room_hash ] = $post_id; + return $post_id; } - return self::$storage_post_id; + return null; } /** * Gets the current time in milliseconds as a comparable time marker. * + * @since 7.0.0 + * * @return int Current time in milliseconds. */ private function get_time_marker(): int { - return floor( microtime( true ) * 1000 ); + return (int) floor( microtime( true ) * 1000 ); } /** @@ -241,7 +268,7 @@ public function get_update_count( string $room ): int { * * @param string $room Room identifier. * @param int $cursor Return updates after this cursor. - * @return array Array of sync updates. + * @return array Sync updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array { $all_updates = $this->get_all_updates( $room ); @@ -256,26 +283,12 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { // Sort by timestamp to ensure order. usort( $updates, - function ( $a, $b ) { - return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); - } + fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] ); return wp_list_pluck( $updates, 'value' ); } - /** - * Removes all sync updates for a given room. - * - * @param string $room Room identifier. - */ - private function remove_all_updates( string $room ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - - delete_post_meta( $post_id, $meta_key ); - } - /** * Removes updates from a room that are older than the given cursor. * @@ -283,20 +296,30 @@ private function remove_all_updates( string $room ): void { * * @param string $room Room identifier. * @param int $cursor Remove updates with markers < this cursor. + * @return bool True on success, false on failure. */ - public function remove_updates_before_cursor( string $room, int $cursor ): void { + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + $post_id = $this->get_storage_post_id( $room ); + if ( null === $post_id ) { + return false; + } + $all_updates = $this->get_all_updates( $room ); - $this->remove_all_updates( $room ); - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); + // Remove all updates for the room and re-store only those that are newer than the cursor. + if ( ! delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) { + return false; + } // Re-store envelopes directly to avoid double-wrapping by add_update(). + $add_result = true; foreach ( $all_updates as $envelope ) { - if ( $envelope['timestamp'] >= $cursor ) { - add_post_meta( $post_id, $meta_key, $envelope, false ); + if ( $add_result && $envelope['timestamp'] >= $cursor ) { + $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); } } + + return $add_result; } } } diff --git a/lib/compat/wordpress-7.0/collaboration.php b/lib/compat/wordpress-7.0/collaboration.php index aff7585da70bd6..c93a26f1ffa4be 100644 --- a/lib/compat/wordpress-7.0/collaboration.php +++ b/lib/compat/wordpress-7.0/collaboration.php @@ -75,7 +75,7 @@ function gutenberg_rest_api_crdt_post_meta() { 'post', $persisted_crdt_post_meta_key, array( - 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { return user_can( $user_id, 'edit_post', $object_id ); }, /* diff --git a/lib/compat/wordpress-7.0/interface-wp-sync-storage.php b/lib/compat/wordpress-7.0/interface-wp-sync-storage.php index 75951d47bcdf87..9cff51043f9281 100644 --- a/lib/compat/wordpress-7.0/interface-wp-sync-storage.php +++ b/lib/compat/wordpress-7.0/interface-wp-sync-storage.php @@ -8,13 +8,6 @@ if ( ! interface_exists( 'WP_Sync_Storage' ) ) { interface WP_Sync_Storage { - /** - * Initializes the storage mechanism. - * - * @since 7.0.0 - */ - public function init(): void; - /** * Adds a sync update to a given room. * @@ -22,8 +15,9 @@ public function init(): void; * * @param string $room Room identifier. * @param mixed $update Serializable sync update, opaque to the storage implementation. + * @return bool True on success, false on failure. */ - public function add_update( string $room, mixed $update ): void; + public function add_update( string $room, $update ): bool; /** * Gets awareness state for a given room. @@ -36,7 +30,7 @@ public function add_update( string $room, mixed $update ): void; public function get_awareness_state( string $room ): array; /** - * Get the current cursor for a given room. This should return a monotonically + * Gets the current cursor for a given room. This should return a monotonically * increasing integer that represents the last update that was returned for the * room during the current request. This allows clients to retrieve updates * after a specific cursor on subsequent requests. @@ -66,7 +60,7 @@ public function get_update_count( string $room ): int; * * @param string $room Room identifier. * @param int $cursor Return updates after this cursor. - * @return array Array of sync updates. + * @return array Sync updates. */ public function get_updates_after_cursor( string $room, int $cursor ): array; @@ -77,8 +71,9 @@ public function get_updates_after_cursor( string $room, int $cursor ): array; * * @param string $room Room identifier. * @param int $cursor Remove updates with markers < this cursor. + * @return bool True on success, false on failure. */ - public function remove_updates_before_cursor( string $room, int $cursor ): void; + public function remove_updates_before_cursor( string $room, int $cursor ): bool; /** * Sets awareness state for a given room. @@ -87,7 +82,8 @@ public function remove_updates_before_cursor( string $room, int $cursor ): void; * * @param string $room Room identifier. * @param array $awareness Serializable awareness state. + * @return bool True on success, false on failure. */ - public function set_awareness_state( string $room, array $awareness ): void; + public function set_awareness_state( string $room, array $awareness ): bool; } } From 368feee3f742b8ff29ffafefa7315a8525b1a399 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 19 Feb 2026 00:30:16 -0500 Subject: [PATCH 2/2] Update backport changelog --- backport-changelog/7.0/10894.md | 1 + 1 file changed, 1 insertion(+) diff --git a/backport-changelog/7.0/10894.md b/backport-changelog/7.0/10894.md index c99d532a375d21..ef7247ad1676ab 100644 --- a/backport-changelog/7.0/10894.md +++ b/backport-changelog/7.0/10894.md @@ -4,4 +4,5 @@ https://github.com/WordPress/wordpress-develop/pull/10894 * https://github.com/WordPress/gutenberg/pull/75681 * https://github.com/WordPress/gutenberg/pull/75682 * https://github.com/WordPress/gutenberg/pull/75708 +* https://github.com/WordPress/gutenberg/pull/75711 * https://github.com/WordPress/gutenberg/pull/75746