From fd2371948d386128c54958dc7993a4bc07690e22 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 00:00:46 -0500 Subject: [PATCH 1/9] Move experimental sync code to lib/compat/wordpress-7.0/ --- ...ss-gutenberg-rest-autosaves-controller.php | 2 +- .../class-wp-http-polling-sync-server.php} | 27 ++-- .../class-wp-sync-post-meta-storage.php} | 6 +- lib/compat/wordpress-7.0/collaboration.php | 109 ++++++++++++++++ .../interface-wp-sync-storage.php} | 4 +- lib/compat/wordpress-7.0/rest-api.php | 22 ++++ lib/experimental/synchronization.php | 120 ------------------ lib/load.php | 11 +- packages/editor/src/store/selectors.js | 4 +- packages/sync/src/providers/index.ts | 2 +- packages/sync/src/types.ts | 2 +- 11 files changed, 157 insertions(+), 152 deletions(-) rename lib/{experimental/sync => compat/wordpress-7.0}/class-gutenberg-rest-autosaves-controller.php (97%) rename lib/{experimental/sync/class-gutenberg-http-polling-sync-server.php => compat/wordpress-7.0/class-wp-http-polling-sync-server.php} (96%) rename lib/{experimental/sync/class-gutenberg-sync-post-meta-storage.php => compat/wordpress-7.0/class-wp-sync-post-meta-storage.php} (95%) create mode 100644 lib/compat/wordpress-7.0/collaboration.php rename lib/{experimental/sync/interface-gutenberg-sync-storage.php => compat/wordpress-7.0/interface-wp-sync-storage.php} (93%) delete mode 100644 lib/experimental/synchronization.php diff --git a/lib/experimental/sync/class-gutenberg-rest-autosaves-controller.php b/lib/compat/wordpress-7.0/class-gutenberg-rest-autosaves-controller.php similarity index 97% rename from lib/experimental/sync/class-gutenberg-rest-autosaves-controller.php rename to lib/compat/wordpress-7.0/class-gutenberg-rest-autosaves-controller.php index 92e224bd5179d3..9f49a627aff7e9 100644 --- a/lib/experimental/sync/class-gutenberg-rest-autosaves-controller.php +++ b/lib/compat/wordpress-7.0/class-gutenberg-rest-autosaves-controller.php @@ -101,7 +101,7 @@ public function create_item( $request ) { * Load the real-time collaboration setting and, when enabled, ensure that an * an autosave revision is always targeted. */ - $is_collaboration_enabled = get_option( 'gutenberg_enable_real_time_collaboration' ); + $is_collaboration_enabled = get_option( 'enable_real_time_collaboration' ); if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { /* diff --git a/lib/experimental/sync/class-gutenberg-http-polling-sync-server.php b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php similarity index 96% rename from lib/experimental/sync/class-gutenberg-http-polling-sync-server.php rename to lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php index 389b56b597b815..aa347fb167d02e 100644 --- a/lib/experimental/sync/class-gutenberg-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php @@ -1,17 +1,18 @@ storage = $storage; } @@ -115,17 +116,15 @@ public function register_routes(): void { 'methods' => array( WP_REST_Server::CREATABLE ), 'callback' => array( $this, 'handle_request' ), 'permission_callback' => array( $this, 'check_permissions' ), - 'args' => array_merge( - array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', - ), - 'required' => true, - 'type' => 'array', + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', ), - ) + 'required' => true, + 'type' => 'array', + ), ), ) ); diff --git a/lib/experimental/sync/class-gutenberg-sync-post-meta-storage.php b/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php similarity index 95% rename from lib/experimental/sync/class-gutenberg-sync-post-meta-storage.php rename to lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php index 4ecd6d03fa541b..4f375f6f510232 100644 --- a/lib/experimental/sync/class-gutenberg-sync-post-meta-storage.php +++ b/lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php @@ -1,12 +1,12 @@ init(); + + $sse_sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sse_sync_server->init(); + } + add_action( 'init', 'gutenberg_rest_api_register_routes_for_collaborative_editing' ); +} + +if ( ! function_exists( 'wp_collaboration_register_meta' ) ) { + /** + * Registers post meta for persisting CRDT documents. + */ + function gutenberg_rest_api_crdt_post_meta() { + // This string must match WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/sync. + $persisted_crdt_post_meta_key = '_crdt_document'; + + register_meta( + 'post', + $persisted_crdt_post_meta_key, + array( + 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { + return user_can( $user_id, 'edit_post', $object_id ); + }, + // IMPORTANT: Revisions must be disabled because we always want to preserve + // the latest persisted CRDT document, even when a revision is restored. + // This ensures that we can continue to apply updates to a shared document + // and peers can simply merge the restored revision like any other incoming + // update. + // + // If we want to persist CRDT documents alongisde revisions in the + // future, we should do so in a separate meta key. + 'revisions_enabled' => false, + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + } + add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' ); +} + +if ( ! function_exists( 'wp_collaboration_inject_setting' ) ) { + /** + * Registers the real-time collaboration setting. + */ + function gutenberg_register_real_time_collaboration_setting() { + $option_name = 'enable_real_time_collaboration'; + + register_setting( + 'writing', + $option_name, + array( + 'type' => 'boolean', + 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ), + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => false, + 'show_in_rest' => true, + ) + ); + + add_settings_field( + $option_name, + __( 'Collaboration', 'gutenberg' ), + function () use ( $option_name ) { + $option_value = get_option( $option_name ); + + ?> + + init(); - - $sse_sync_server = new Gutenberg_HTTP_Polling_Sync_Server( $sync_storage ); - $sse_sync_server->init(); -} -add_action( 'init', 'gutenberg_rest_api_register_routes_for_collaborative_editing' ); - -/** - * Registers post meta for persisting CRDT documents. - */ -function gutenberg_rest_api_crdt_post_meta() { - // This string must match WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/sync. - $persisted_crdt_post_meta_key = '_crdt_document'; - - register_meta( - 'post', - $persisted_crdt_post_meta_key, - array( - 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { - return user_can( $user_id, 'edit_post', $object_id ); - }, - // IMPORTANT: Revisions must be disabled because we always want to preserve - // the latest persisted CRDT document, even when a revision is restored. - // This ensures that we can continue to apply updates to a shared document - // and peers can simply merge the restored revision like any other incoming - // update. - // - // If we want to persist CRDT documents alongisde revisions in the - // future, we should do so in a separate meta key. - 'revisions_enabled' => false, - 'show_in_rest' => true, - 'single' => true, - 'type' => 'string', - ) - ); -} -add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' ); - -/** - * Registers the real-time collaboration setting. - */ -function gutenberg_register_real_time_collaboration_setting() { - $option_name = 'gutenberg_enable_real_time_collaboration'; - - register_setting( - 'writing', - $option_name, - array( - 'type' => 'boolean', - 'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ), - 'sanitize_callback' => 'rest_sanitize_boolean', - 'default' => false, - 'show_in_rest' => true, - ) - ); - - add_settings_field( - $option_name, - __( 'Collaboration', 'gutenberg' ), - function () use ( $option_name ) { - $option_value = get_option( $option_name ); - - ?> - - Date: Tue, 10 Feb 2026 01:16:04 -0500 Subject: [PATCH 2/9] Guard class creation --- .../class-wp-http-polling-sync-server.php | 793 +++++++++--------- .../class-wp-sync-post-meta-storage.php | 20 +- .../interface-wp-sync-storage.php | 81 +- lib/compat/wordpress-7.0/rest-api.php | 1 - 4 files changed, 450 insertions(+), 445 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 aa347fb167d02e..e070d1de255d83 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 @@ -5,459 +5,462 @@ * @package Gutenberg */ -/** - * Class that implements an HTTP server used to sync collaborative editing - * updates. - * - * @access private - * @internal - */ -class WP_HTTP_Polling_Sync_Server { - /** - * REST API namespace. - */ - const REST_NAMESPACE = 'wp/v2/sync'; +if ( ! class_exists( 'WP_HTTP_Polling_Sync_Server' ) ) { /** - * Awareness timeout in seconds. Clients that haven't updated - * their awareness state within this time are considered disconnected. - */ - const AWARENESS_TIMEOUT_IN_S = 30; // 30 seconds - - /** - * Threshold used to signal clients to send a compaction update. - */ - const COMPACTION_THRESHOLD = 50; - - /** - * Sync update types. - */ - const UPDATE_TYPE_COMPACTION = 'compaction'; - const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1'; - const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2'; - const UPDATE_TYPE_UPDATE = 'update'; - - /** - * Storage backend for sync updates. + * Class that implements an HTTP server used to sync collaborative editing + * updates. * - * @var Gutenberg_Sync_Storage + * @access private + * @internal */ - private $storage; + class WP_HTTP_Polling_Sync_Server { + /** + * REST API namespace. + */ + const REST_NAMESPACE = 'wp/v2/sync'; + + /** + * Awareness timeout in seconds. Clients that haven't updated + * their awareness state within this time are considered disconnected. + */ + const AWARENESS_TIMEOUT_IN_S = 30; // 30 seconds + + /** + * Threshold used to signal clients to send a compaction update. + */ + const COMPACTION_THRESHOLD = 50; + + /** + * Sync update types. + */ + const UPDATE_TYPE_COMPACTION = 'compaction'; + const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1'; + const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2'; + const UPDATE_TYPE_UPDATE = 'update'; + + /** + * Storage backend for sync updates. + * + * @var Gutenberg_Sync_Storage + */ + private $storage; + + public function __construct( WP_Sync_Storage $storage ) { + $this->storage = $storage; + } - public function __construct( WP_Sync_Storage $storage ) { - $this->storage = $storage; - } + /** + * Initialize the sync server. + */ + public function init(): void { + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } - /** - * Initialize the sync server. - */ - public function init(): void { - add_action( 'rest_api_init', array( $this, 'register_routes' ) ); - } + /** + * Register REST API routes. + */ + public function register_routes(): void { + $typed_update_args = array( + 'properties' => array( + 'data' => array( + 'type' => 'string', + 'required' => true, + ), + 'type' => array( + 'type' => 'string', + 'required' => true, + 'enum' => array( + self::UPDATE_TYPE_COMPACTION, + self::UPDATE_TYPE_SYNC_STEP1, + self::UPDATE_TYPE_SYNC_STEP2, + self::UPDATE_TYPE_UPDATE, + ), + ), + ), + 'required' => true, + 'type' => 'object', + ); - /** - * Register REST API routes. - */ - public function register_routes(): void { - $typed_update_args = array( - 'properties' => array( - 'data' => array( - 'type' => 'string', + $room_args = array( + 'after' => array( + 'minimum' => 0, 'required' => true, + 'type' => 'integer', ), - 'type' => array( - 'type' => 'string', + 'awareness' => array( 'required' => true, - 'enum' => array( - self::UPDATE_TYPE_COMPACTION, - self::UPDATE_TYPE_SYNC_STEP1, - self::UPDATE_TYPE_SYNC_STEP2, - self::UPDATE_TYPE_UPDATE, - ), + 'type' => 'object', + ), + 'client_id' => array( + 'minimum' => 1, + 'required' => true, + 'type' => 'integer', + ), + 'room' => array( + 'sanitize_callback' => 'sanitize_text_field', + 'required' => true, + 'type' => 'string', ), - ), - 'required' => true, - 'type' => 'object', - ); - - $room_args = array( - 'after' => array( - 'minimum' => 0, - 'required' => true, - 'type' => 'integer', - ), - 'awareness' => array( - 'required' => true, - 'type' => 'object', - ), - 'client_id' => array( - 'minimum' => 1, - 'required' => true, - 'type' => 'integer', - ), - 'room' => array( - 'sanitize_callback' => 'sanitize_text_field', - 'required' => true, - 'type' => 'string', - ), - 'updates' => array( - 'items' => $typed_update_args, - 'minItems' => 0, - 'required' => true, - 'type' => 'array', - ), - ); - - // POST /wp/v2/sync/updates - register_rest_route( - self::REST_NAMESPACE, - '/updates', - array( - 'methods' => array( WP_REST_Server::CREATABLE ), - 'callback' => array( $this, 'handle_request' ), - 'permission_callback' => array( $this, 'check_permissions' ), - 'args' => array( - 'rooms' => array( - 'items' => array( - 'properties' => $room_args, - 'type' => 'object', + 'updates' => array( + 'items' => $typed_update_args, + 'minItems' => 0, + 'required' => true, + 'type' => 'array', + ), + ); + + // POST /wp/v2/sync/updates + register_rest_route( + self::REST_NAMESPACE, + '/updates', + array( + 'methods' => array( WP_REST_Server::CREATABLE ), + 'callback' => array( $this, 'handle_request' ), + 'permission_callback' => array( $this, 'check_permissions' ), + 'args' => array( + 'rooms' => array( + 'items' => array( + 'properties' => $room_args, + 'type' => 'object', + ), + 'required' => true, + 'type' => 'array', ), - 'required' => true, - 'type' => 'array', ), - ), - ) - ); - } + ) + ); + } - /** - * Check if the current user has permission to access a room. - * - * @param WP_REST_Request $request The REST request. - * @return bool|WP_Error True if user has permission, otherwise WP_Error with details. - */ - public function check_permissions( WP_REST_Request $request ) { - $rooms = $request->get_param( 'rooms' ); - - foreach ( $rooms as $room ) { - // Parse sync object type (format: kind/name) - $room = $room['room']; - $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', - array( 'status' => 400 ) - ); + /** + * Check if the current user has permission to access a room. + * + * @param WP_REST_Request $request The REST request. + * @return bool|WP_Error True if user has permission, otherwise WP_Error with details. + */ + public function check_permissions( WP_REST_Request $request ) { + $rooms = $request->get_param( 'rooms' ); + + foreach ( $rooms as $room ) { + // Parse sync object type (format: kind/name) + $room = $room['room']; + $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', + array( 'status' => 400 ) + ); + } + + // Extract Gutenberg entity kind, entity name and object ID. + $entity_kind = $type_parts[0]; + $entity_name = $object_parts[0]; + $object_id = null; + + if ( isset( $object_parts[1] ) ) { + $object_id = $object_parts[1]; + } + + if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + return new WP_Error( + 'forbidden', + sprintf( 'You do not have permission to sync this entity: %s.', $room ), + array( 'status' => 401 ) + ); + } } - // Extract Gutenberg entity kind, entity name and object ID. - $entity_kind = $type_parts[0]; - $entity_name = $object_parts[0]; - $object_id = null; + return true; + } - if ( isset( $object_parts[1] ) ) { - $object_id = $object_parts[1]; + /** + * Check 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 int|null $object_id The object ID (if applicable). + * @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. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + return current_user_can( 'edit_post', absint( $object_id ) ); } - if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { - return new WP_Error( - 'forbidden', - sprintf( 'You do not have permission to sync this entity: %s.', $room ), - array( 'status' => 401 ) - ); + // All of the remaining checks for for collections. If an object ID is + // provided, reject the request. + if ( null !== $object_id ) { + return false; } - } - return true; - } + // 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. + $allowed_collection_entity_kinds = array( + 'postType', + 'root', + 'taxonomy', + ); - /** - * Check 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 int|null $object_id The object ID (if applicable). - * @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. - if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { - return current_user_can( 'edit_post', absint( $object_id ) ); + return in_array( $entity_kind, $allowed_collection_entity_kinds, true ); } - // All of the remaining checks for for collections. If an object ID is - // provided, reject the request. - if ( null !== $object_id ) { - return false; - } + /** + * Handle request: store sync updates and awareness data, and return updates + * the client is missing. + * + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response|WP_Error Response object or error. + */ + public function handle_request( WP_REST_Request $request ) { + $rooms = $request->get_param( 'rooms' ); + $response = array( + 'rooms' => array(), + ); - // 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. - $allowed_collection_entity_kinds = array( - 'postType', - 'root', - 'taxonomy', - ); + foreach ( $rooms as $room_request ) { + $awareness = $room_request['awareness']; + $client_id = $room_request['client_id']; + $cursor = $room_request['after']; + $room = $room_request['room']; - return in_array( $entity_kind, $allowed_collection_entity_kinds, true ); - } + // Merge awareness state. + $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness ); - /** - * Handle request: store sync updates and awareness data, and return updates - * the client is missing. - * - * @param WP_REST_Request $request The REST request. - * @return WP_REST_Response|WP_Error Response object or error. - */ - public function handle_request( WP_REST_Request $request ) { - $rooms = $request->get_param( 'rooms' ); - $response = array( - 'rooms' => array(), - ); - - foreach ( $rooms as $room_request ) { - $awareness = $room_request['awareness']; - $client_id = $room_request['client_id']; - $cursor = $room_request['after']; - $room = $room_request['room']; - - // Merge awareness state. - $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; - - // Process each update according to its type. - foreach ( $room_request['updates'] as $update ) { - $this->process_sync_update( $room, $client_id, $cursor, $update ); - } + // The lowest client ID is nominated to perform compaction when needed. + $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id; - // Get updates for this client. - $room_response = $this->get_updates_after( $room, $client_id, $cursor, $is_compactor ); - $room_response['awareness'] = $merged_awareness; + // Process each update according to its type. + foreach ( $room_request['updates'] as $update ) { + $this->process_sync_update( $room, $client_id, $cursor, $update ); + } - $response['rooms'][] = $room_response; + // Get updates for this client. + $room_response = $this->get_updates_after( $room, $client_id, $cursor, $is_compactor ); + $room_response['awareness'] = $merged_awareness; + + $response['rooms'][] = $room_response; + } + + return new WP_REST_Response( $response, 200 ); } - return new WP_REST_Response( $response, 200 ); - } + /** + * Process and store an awareness update from a client. + * + * @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. + */ + private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { + // Get existing awareness state and filter out expired clients. + $existing_awareness = $this->storage->get_awareness_state( $room ); + $updated_awareness = array(); + $current_time = time(); + + foreach ( $existing_awareness as $entry ) { + // Remove this client's entry (it will be updated below). + if ( $client_id === $entry['client_id'] ) { + continue; + } - /** - * Process and store an awareness update from a client. - * - * @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. - */ - private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { - // Get existing awareness state and filter out expired clients. - $existing_awareness = $this->storage->get_awareness_state( $room ); - $updated_awareness = array(); - $current_time = time(); - - foreach ( $existing_awareness as $entry ) { - // Remove this client's entry (it will be updated below). - if ( $client_id === $entry['client_id'] ) { - continue; + // Remove entries that have expired. + if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) { + continue; + } + + $updated_awareness[] = $entry; } - // Remove entries that have expired. - if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT_IN_S ) { - continue; + // Add this client's awareness state. + if ( null !== $awareness_update ) { + $updated_awareness[] = array( + 'client_id' => $client_id, + 'state' => $awareness_update, + 'updated_at' => $current_time, + ); } - $updated_awareness[] = $entry; - } + // Save updated awareness state. + $this->storage->set_awareness_state( $room, $updated_awareness ); - // Add this client's awareness state. - if ( null !== $awareness_update ) { - $updated_awareness[] = array( - 'client_id' => $client_id, - 'state' => $awareness_update, - 'updated_at' => $current_time, - ); - } - - // Save updated awareness state. - $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']; + } - // Convert to client_id => state map for response. - $response = array(); - foreach ( $updated_awareness as $entry ) { - $response[ $entry['client_id'] ] = (object) $entry['state']; + return $response; } - return $response; - } - - /** - * Process 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. - */ - private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { - $data = $update['data']; - $type = $update['type']; - - switch ( $type ) { - case self::UPDATE_TYPE_COMPACTION: - // Compaction replaces updates the client has already seen. Only remove - // updates with markers before the client's cursor to preserve updates - // that arrived since the client's last sync. - // - // The `remove_updates_before_compaction` method returns false if there - // is a newer compaction update already stored. - if ( $this->remove_updates_before_cursor( $room, $cursor ) ) { + /** + * Process 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. + */ + private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { + $data = $update['data']; + $type = $update['type']; + + switch ( $type ) { + case self::UPDATE_TYPE_COMPACTION: + // Compaction replaces updates the client has already seen. Only remove + // updates with markers before the client's cursor to preserve updates + // that arrived since the client's last sync. + // + // The `remove_updates_before_compaction` method returns false if there + // is a newer compaction update already stored. + if ( $this->remove_updates_before_cursor( $room, $cursor ) ) { + $this->add_update( $room, $client_id, $type, $data ); + } + break; + + case self::UPDATE_TYPE_SYNC_STEP1: + // Sync step 1 announces a client's state vector. Other clients need + // to see it so they can respond with sync_step2 containing missing + // updates. The cursor-based filtering prevents re-delivery. $this->add_update( $room, $client_id, $type, $data ); - } - break; - - case self::UPDATE_TYPE_SYNC_STEP1: - // Sync step 1 announces a client's state vector. Other clients need - // to see it so they can respond with sync_step2 containing missing - // updates. The cursor-based filtering prevents re-delivery. - $this->add_update( $room, $client_id, $type, $data ); - break; - - case self::UPDATE_TYPE_SYNC_STEP2: - // Sync step 2 contains updates for a specific client. - // Store it but mark for single delivery (will be cleaned up after delivery). - $this->add_update( $room, $client_id, $type, $data ); - break; - - case self::UPDATE_TYPE_UPDATE: - // Regular document updates are stored persistently. - $this->add_update( $room, $client_id, $type, $data ); - break; - } - } - - /** - * Add an update to a room's update list. - * - * @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. - */ - private function add_update( string $room, int $client_id, string $type, string $data ): void { - $update_envelope = array( - 'client_id' => $client_id, - 'type' => $type, - 'data' => $data, - 'timestamp' => $this->get_time_marker(), - ); - - // Store the update - $this->storage->add_update( $room, $update_envelope ); - } + break; - /** - * Get the current time in milliseconds as a comparable time marker. - * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return floor( microtime( true ) * 1000 ); - } - - /** - * Get sync updates from a room after a given cursor. - * - * @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 - */ - private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { - $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency - $all_updates = $this->storage->get_all_updates( $room ); - $total_updates = count( $all_updates ); - $updates = array(); - - foreach ( $all_updates as $update ) { - // Skip updates from this client, unless they are compaction updates. - if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { - continue; - } + case self::UPDATE_TYPE_SYNC_STEP2: + // Sync step 2 contains updates for a specific client. + // Store it but mark for single delivery (will be cleaned up after delivery). + $this->add_update( $room, $client_id, $type, $data ); + break; - // Skip updates before our cursor. - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; + case self::UPDATE_TYPE_UPDATE: + // Regular document updates are stored persistently. + $this->add_update( $room, $client_id, $type, $data ); + break; } } - // Sort by update timestamp to ensure order - usort( - $updates, - function ( $a, $b ) { - return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); - } - ); - - // Convert to typed update format for response. - $typed_updates = array(); - foreach ( $updates as $update ) { - $typed_updates[] = array( - 'data' => $update['data'], - 'type' => $update['type'], + /** + * Add an update to a room's update list. + * + * @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. + */ + private function add_update( string $room, int $client_id, string $type, string $data ): void { + $update_envelope = array( + 'client_id' => $client_id, + 'type' => $type, + 'data' => $data, + 'timestamp' => $this->get_time_marker(), ); + + // Store the update + $this->storage->add_update( $room, $update_envelope ); } - // Determine if this client should perform compaction. - $compaction_request = null; - if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { - $compaction_request = $all_updates; + /** + * Get the current time in milliseconds as a comparable time marker. + * + * @return int Current time in milliseconds. + */ + private function get_time_marker(): int { + return floor( microtime( true ) * 1000 ); } - return array( - 'compaction_request' => $compaction_request, - 'end_cursor' => $end_cursor, - 'room' => $room, - 'total_updates' => $total_updates, - 'updates' => $typed_updates, - ); - } + /** + * Get sync updates from a room after a given cursor. + * + * @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 + */ + private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { + $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency + $all_updates = $this->storage->get_all_updates( $room ); + $total_updates = count( $all_updates ); + $updates = array(); + + foreach ( $all_updates as $update ) { + // Skip updates from this client, unless they are compaction updates. + if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { + continue; + } - /** - * Remove updates from a room that are older than the given compaction marker. - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True if this compaction is the latest, false if a newer compaction update exists. - */ - private function remove_updates_before_cursor( string $room, int $cursor ): bool { - $all_updates = $this->storage->get_all_updates( $room ); - $this->storage->remove_all_updates( $room ); + // Skip updates before our cursor. + if ( $update['timestamp'] > $cursor ) { + $updates[] = $update; + } + } - $is_latest_compaction = true; - $updates_to_keep = array(); + // Sort by update timestamp to ensure order + usort( + $updates, + function ( $a, $b ) { + return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); + } + ); - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] >= $cursor ) { - $updates_to_keep[] = $update; + // Convert to typed update format for response. + $typed_updates = array(); + foreach ( $updates as $update ) { + $typed_updates[] = array( + 'data' => $update['data'], + 'type' => $update['type'], + ); + } - if ( self::UPDATE_TYPE_COMPACTION === $update['type'] ) { - // If there is already a newer compaction update, return false. - $is_latest_compaction = false; - } + // Determine if this client should perform compaction. + $compaction_request = null; + if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { + $compaction_request = $all_updates; } - } - // Replace all updates with filtered list. - foreach ( $updates_to_keep as $update ) { - $this->storage->add_update( $room, $update ); + return array( + 'compaction_request' => $compaction_request, + 'end_cursor' => $end_cursor, + 'room' => $room, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, + ); } - return $is_latest_compaction; + /** + * Remove updates from a room that are older than the given compaction marker. + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. + * @return bool True if this compaction is the latest, false if a newer compaction update exists. + */ + private function remove_updates_before_cursor( string $room, int $cursor ): bool { + $all_updates = $this->storage->get_all_updates( $room ); + $this->storage->remove_all_updates( $room ); + + $is_latest_compaction = true; + $updates_to_keep = array(); + + foreach ( $all_updates as $update ) { + if ( $update['timestamp'] >= $cursor ) { + $updates_to_keep[] = $update; + + if ( self::UPDATE_TYPE_COMPACTION === $update['type'] ) { + // If there is already a newer compaction update, return false. + $is_latest_compaction = false; + } + } + } + + // Replace all updates with filtered list. + foreach ( $updates_to_keep as $update ) { + $this->storage->add_update( $room, $update ); + } + + return $is_latest_compaction; + } } } 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 4f375f6f510232..681c901928915d 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 @@ -5,15 +5,15 @@ * @package Gutenberg */ -/** - * Class that implements 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. - * - * @access private - * @internal - */ +if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) [ /** + * Class that implements 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. + * + * @access private + * @internal + */ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** * Post type for sync storage @@ -180,4 +180,4 @@ public function set_awareness_state( string $room, array $awareness ): void { update_post_meta( $post_id, $meta_key, $awareness ); } -} +} ] 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 3b6fb7e04aa372..1e7ca89f6e3660 100644 --- a/lib/compat/wordpress-7.0/interface-wp-sync-storage.php +++ b/lib/compat/wordpress-7.0/interface-wp-sync-storage.php @@ -5,48 +5,51 @@ * @package Gutenberg */ -interface WP_Sync_Storage { - /** - * Initialize the storage mechanism. - */ - public function init(): void; +if ( ! interface_exists( 'WP_Sync_Storage' ) ) { - /** - * Add a sync update to a given room. - * - * @param string $room Room identifier. - * @param array $update Sync update. - */ - public function add_update( string $room, array $update ): void; + interface WP_Sync_Storage { + /** + * Initialize the storage mechanism. + */ + public function init(): void; - /** - * Retrieve sync updates for a given room. - * - * @param string $room Room identifier. - * @return array Array of sync updates. - */ - public function get_all_updates( string $room ): array; + /** + * Add a sync update to a given room. + * + * @param string $room Room identifier. + * @param array $update Sync update. + */ + public function add_update( string $room, array $update ): void; - /** - * Get awareness state for a given room. - * - * @param string $room Room identifier. - * @return array Merged awarenessstate. - */ - public function get_awareness_state( string $room ): array; + /** + * Retrieve sync updates for a given room. + * + * @param string $room Room identifier. + * @return array Array of sync updates. + */ + public function get_all_updates( string $room ): array; - /** - * Remove all updates for a given room. - * - * @param string $room Room identifier. - */ - public function remove_all_updates( string $room ): void; + /** + * Get awareness state for a given room. + * + * @param string $room Room identifier. + * @return array Merged awarenessstate. + */ + public function get_awareness_state( string $room ): array; - /** - * Set awareness state for a given room. - * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. - */ - public function set_awareness_state( string $room, array $awareness ): void; + /** + * Remove all updates for a given room. + * + * @param string $room Room identifier. + */ + public function remove_all_updates( string $room ): void; + + /** + * Set awareness state for a given room. + * + * @param string $room Room identifier. + * @param array $awareness Merged awareness state. + */ + public function set_awareness_state( string $room, array $awareness ): void; + } } diff --git a/lib/compat/wordpress-7.0/rest-api.php b/lib/compat/wordpress-7.0/rest-api.php index 429545276b012b..e35acdb12c6321 100644 --- a/lib/compat/wordpress-7.0/rest-api.php +++ b/lib/compat/wordpress-7.0/rest-api.php @@ -129,4 +129,3 @@ function gutenberg_override_autosaves_rest_controller( $args ) { } add_filter( 'register_post_type_args', 'gutenberg_override_autosaves_rest_controller', 10, 1 ); - From 874c9e21c223abb88daf8d7fdb5051e2a6787969 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 01:32:49 -0500 Subject: [PATCH 3/9] Add backport changelog file --- backport-changelog/7.0/10894.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.0/10894.md diff --git a/backport-changelog/7.0/10894.md b/backport-changelog/7.0/10894.md new file mode 100644 index 00000000000000..f55e198f0d95f3 --- /dev/null +++ b/backport-changelog/7.0/10894.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/10894 + +* https://github.com/WordPress/gutenberg/pull/75366 From b4dc409d4852606736bc051d35bda3960e26cad0 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 01:35:13 -0500 Subject: [PATCH 4/9] Fix class_exists check --- .../class-wp-sync-post-meta-storage.php | 307 +++++++++--------- 1 file changed, 155 insertions(+), 152 deletions(-) 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 681c901928915d..764610b4c9eb2d 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 @@ -5,7 +5,9 @@ * @package Gutenberg */ -if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) [ /** +if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) ) { + + /** * Class that implements an interface for storing and retrieving sync * updates and awareness data during a collaborative session. * @@ -14,170 +16,171 @@ * @access private * @internal */ -class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { - /** - * Post type for sync storage - */ - const POST_TYPE = 'sync_storage'; - - /** - * Singleton post ID for storing sync data - * - * @var int|null - */ - private static $storage_post_id = null; - - /** - * Register the custom post type for sync storage. - */ - public function init(): void { - register_post_type( - self::POST_TYPE, - array( - 'label' => 'Gutenberg Sync Storage', - 'public' => false, - 'publicly_queryable' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } - - /** - * Add a sync update to a given room. - * - * @param string $room Room identifier. - * @param array $update Sync update. - */ - public function add_update( string $room, array $update ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - - add_post_meta( $post_id, $meta_key, $update, false ); - } - - /** - * Retrieve sync updates for a given room. - * - * @param string $room Room identifier. - * @return array Array of sync updates. - */ - public function get_all_updates( string $room ): array { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); - $updates = get_post_meta( $post_id, $meta_key, false ); - - if ( ! is_array( $updates ) ) { - $updates = array(); + class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { + /** + * Post type for sync storage + */ + const POST_TYPE = 'sync_storage'; + + /** + * Singleton post ID for storing sync data + * + * @var int|null + */ + private static $storage_post_id = null; + + /** + * Register the custom post type for sync storage. + */ + public function init(): void { + register_post_type( + self::POST_TYPE, + array( + 'label' => 'Gutenberg Sync Storage', + 'public' => false, + 'publicly_queryable' => false, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'show_ui' => false, + 'supports' => array( 'custom-fields' ), + ) + ); } - return $updates; - } - - /** - * Get 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 'sync_awareness_' . md5( $room ); - } - - /** - * Get awareness state for a given room. - * - * @param string $room Room identifier. - * @return array Merged awarenessstate. - */ - 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 ); - - if ( ! is_array( $awareness ) ) { - return array(); + /** + * Add a sync update to a given room. + * + * @param string $room Room identifier. + * @param array $update Sync update. + */ + public function add_update( string $room, array $update ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + add_post_meta( $post_id, $meta_key, $update, false ); } - return $awareness; - } - - /** - * Get the meta key for a room's updates. - * - * @param string $room Room identifier. - * @return string Meta key. - */ - private function get_room_meta_key( string $room ): string { - return 'sync_update_' . md5( $room ); - } + /** + * Retrieve sync updates for a given room. + * + * @param string $room Room identifier. + * @return array Array of sync updates. + */ + public function get_all_updates( string $room ): array { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + $updates = get_post_meta( $post_id, $meta_key, false ); + + if ( ! is_array( $updates ) ) { + $updates = array(); + } + + return $updates; + } - /** - * Get or create the singleton post for storing sync data. - * - * @return int Post ID. - */ - private function get_storage_post_id(): int { - if ( is_int( self::$storage_post_id ) ) { - return self::$storage_post_id; + /** + * Get 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 'sync_awareness_' . md5( $room ); } - // Try to find existing post - $posts = get_posts( - array( - 'post_type' => self::POST_TYPE, - 'posts_per_page' => 1, - 'post_status' => 'publish', - 'orderby' => 'ID', - 'order' => 'ASC', - ) - ); - - if ( ! empty( $posts ) ) { - self::$storage_post_id = $posts[0]->ID; - return self::$storage_post_id; + /** + * Get awareness state for a given room. + * + * @param string $room Room identifier. + * @return array Merged awarenessstate. + */ + 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 ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return $awareness; } - // Create new post if none exists - $post_id = wp_insert_post( - array( - 'post_type' => self::POST_TYPE, - 'post_status' => 'publish', - 'post_title' => 'Gutenberg Sync Storage', - ) - ); - - if ( ! is_wp_error( $post_id ) ) { - self::$storage_post_id = $post_id; + /** + * Get the meta key for a room's updates. + * + * @param string $room Room identifier. + * @return string Meta key. + */ + private function get_room_meta_key( string $room ): string { + return 'sync_update_' . md5( $room ); } - return self::$storage_post_id; - } + /** + * Get or create the singleton post for storing sync data. + * + * @return int Post ID. + */ + private function get_storage_post_id(): int { + if ( is_int( self::$storage_post_id ) ) { + return self::$storage_post_id; + } + + // Try to find existing post + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => 1, + 'post_status' => 'publish', + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + if ( ! empty( $posts ) ) { + self::$storage_post_id = $posts[0]->ID; + return self::$storage_post_id; + } + + // Create new post if none exists + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => 'Gutenberg Sync Storage', + ) + ); + + if ( ! is_wp_error( $post_id ) ) { + self::$storage_post_id = $post_id; + } - /** - * Remove all sync updates for a given room. - * - * @param string $room Room identifier. - */ - public function remove_all_updates( string $room ): void { - $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_room_meta_key( $room ); + return self::$storage_post_id; + } - delete_post_meta( $post_id, $meta_key ); - } + /** + * Remove all sync updates for a given room. + * + * @param string $room Room identifier. + */ + public function remove_all_updates( string $room ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); - /** - * Set awareness state for a given room. - * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. - */ - 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 ); + delete_post_meta( $post_id, $meta_key ); + } - update_post_meta( $post_id, $meta_key, $awareness ); + /** + * Set awareness state for a given room. + * + * @param string $room Room identifier. + * @param array $awareness Merged awareness state. + */ + 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 ); + } } -} ] +} From ae3799f5e40df00b82c119127bbacef4be5a2089 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 16:24:52 -0500 Subject: [PATCH 5/9] Update REST endpoint --- .../wordpress-7.0/class-wp-http-polling-sync-server.php | 4 ++-- packages/sync/src/providers/http-polling/README.md | 4 ++-- packages/sync/src/providers/http-polling/test/utils.test.ts | 2 +- packages/sync/src/providers/http-polling/utils.ts | 2 +- 4 files changed, 6 insertions(+), 6 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 e070d1de255d83..f3cd3c45daff5f 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 @@ -18,7 +18,7 @@ class WP_HTTP_Polling_Sync_Server { /** * REST API namespace. */ - const REST_NAMESPACE = 'wp/v2/sync'; + const REST_NAMESPACE = 'wp-sync/v1'; /** * Awareness timeout in seconds. Clients that haven't updated @@ -110,7 +110,7 @@ public function register_routes(): void { ), ); - // POST /wp/v2/sync/updates + // POST /wp-sync/v1/updates register_rest_route( self::REST_NAMESPACE, '/updates', diff --git a/packages/sync/src/providers/http-polling/README.md b/packages/sync/src/providers/http-polling/README.md index 3c6f4db04b2533..42d729d4758e4a 100644 --- a/packages/sync/src/providers/http-polling/README.md +++ b/packages/sync/src/providers/http-polling/README.md @@ -18,7 +18,7 @@ A Yjs provider for Gutenberg that enables real-time synchronization via HTTP pol │ └─────┬─────┘ │ │ └─────┬─────┘ │ └────────┼────────┘ └────────┼────────┘ │ │ - │ POST /wp/v2/sync/updates │ + │ POST /wp-sync/v1/updates │ └────────────────────┬───────────────────────────┘ │ ┌─────────┴─────────┐ @@ -127,7 +127,7 @@ Awareness state (presence, cursors) is synchronized alongside document updates: ## REST API -### POST `/wp/v2/sync/updates` +### POST `/wp-sync/v1/updates` Single endpoint for bidirectional sync including awareness. Clients send their updates and receive updates from others in one request. diff --git a/packages/sync/src/providers/http-polling/test/utils.test.ts b/packages/sync/src/providers/http-polling/test/utils.test.ts index 78d4a935ff78ea..5856b3b5dd419e 100644 --- a/packages/sync/src/providers/http-polling/test/utils.test.ts +++ b/packages/sync/src/providers/http-polling/test/utils.test.ts @@ -599,7 +599,7 @@ describe( 'http-polling utils', () => { }, method: 'POST', parse: false, - path: '/wp/v2/sync/updates', + path: '/wp-sync/v1/updates', } ); } ); diff --git a/packages/sync/src/providers/http-polling/utils.ts b/packages/sync/src/providers/http-polling/utils.ts index aa1503e30faaad..07e37f36d627d4 100644 --- a/packages/sync/src/providers/http-polling/utils.ts +++ b/packages/sync/src/providers/http-polling/utils.ts @@ -14,7 +14,7 @@ import { type UpdateQueue, } from './types'; -const SYNC_API_PATH = '/wp/v2/sync/updates'; +const SYNC_API_PATH = '/wp-sync/v1/updates'; export function uint8ArrayToBase64( data: Uint8Array ): string { let binary = ''; From 0e621abcf7e57cfe9e02451cc934869272880d5b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 13 Feb 2026 09:48:09 -0500 Subject: [PATCH 6/9] Updates from backport PR --- .../class-wp-http-polling-sync-server.php | 325 +++++++++--------- .../class-wp-sync-post-meta-storage.php | 236 +++++++++---- lib/compat/wordpress-7.0/collaboration.php | 74 +++- .../interface-wp-sync-storage.php | 70 +++- 4 files changed, 443 insertions(+), 262 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 f3cd3c45daff5f..83dc8d9e619f50 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 @@ -2,63 +2,98 @@ /** * WP_HTTP_Polling_Sync_Server class * - * @package Gutenberg + * @package gutenberg */ if ( ! class_exists( 'WP_HTTP_Polling_Sync_Server' ) ) { /** - * Class that implements an HTTP server used to sync collaborative editing - * updates. + * Core class that contains an HTTP server used for collaborative editing. * + * @since 7.0.0 * @access private - * @internal */ class WP_HTTP_Polling_Sync_Server { /** * REST API namespace. + * + * @since 7.0.0 + * @var string */ const REST_NAMESPACE = 'wp-sync/v1'; /** * Awareness timeout in seconds. Clients that haven't updated * their awareness state within this time are considered disconnected. + * + * @since 7.0.0 + * @var int */ - const AWARENESS_TIMEOUT_IN_S = 30; // 30 seconds + const AWARENESS_TIMEOUT_IN_S = 30; /** * Threshold used to signal clients to send a compaction update. + * + * @since 7.0.0 + * @var int */ const COMPACTION_THRESHOLD = 50; /** - * Sync update types. + * Sync update type: compaction. + * + * @since 7.0.0 + * @var string */ const UPDATE_TYPE_COMPACTION = 'compaction'; + + /** + * Sync update type: sync step 1. + * + * @since 7.0.0 + * @var string + */ const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1'; + + /** + * Sync update type: sync step 2. + * + * @since 7.0.0 + * @var string + */ const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2'; - const UPDATE_TYPE_UPDATE = 'update'; + + /** + * Sync update type: regular update. + * + * @since 7.0.0 + * @var string + */ + const UPDATE_TYPE_UPDATE = 'update'; /** * Storage backend for sync updates. * - * @var Gutenberg_Sync_Storage + * @since 7.0.0 + * @var WP_Sync_Storage */ private $storage; - public function __construct( WP_Sync_Storage $storage ) { - $this->storage = $storage; - } - /** - * Initialize the sync server. + * Constructor. + * + * @since 7.0.0 + * + * @param WP_Sync_Storage $storage Storage backend for sync updates. */ - public function init(): void { - add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + public function __construct( WP_Sync_Storage $storage ) { + $this->storage = $storage; } /** - * Register REST API routes. + * Registers REST API routes. + * + * @since 7.0.0 */ public function register_routes(): void { $typed_update_args = array( @@ -110,7 +145,6 @@ public function register_routes(): void { ), ); - // POST /wp-sync/v1/updates register_rest_route( self::REST_NAMESPACE, '/updates', @@ -133,16 +167,17 @@ public function register_routes(): void { } /** - * Check if the current user has permission to access a room. + * Checks if the current user has permission to access a room. + * + * @since 7.0.0 * * @param WP_REST_Request $request The REST request. * @return bool|WP_Error True if user has permission, otherwise WP_Error with details. */ public function check_permissions( WP_REST_Request $request ) { - $rooms = $request->get_param( 'rooms' ); + $rooms = $request['rooms']; foreach ( $rooms as $room ) { - // Parse sync object type (format: kind/name) $room = $room['room']; $type_parts = explode( '/', $room, 2 ); $object_parts = explode( ':', $type_parts[1] ?? '', 2 ); @@ -150,24 +185,23 @@ public function check_permissions( WP_REST_Request $request ) { 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', + __( 'Invalid room format. Expected: entity_kind/entity_name or entity_kind/entity_name:id', 'gutenberg' ), array( 'status' => 400 ) ); } - // Extract Gutenberg entity kind, entity name and object ID. $entity_kind = $type_parts[0]; $entity_name = $object_parts[0]; - $object_id = null; - - if ( isset( $object_parts[1] ) ) { - $object_id = $object_parts[1]; - } + $object_id = $object_parts[1] ?? null; if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { return new WP_Error( 'forbidden', - sprintf( 'You do not have permission to sync this entity: %s.', $room ), + 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 ) ); } @@ -177,46 +211,16 @@ public function check_permissions( WP_REST_Request $request ) { } /** - * Check if the current user can sync a specific entity type. + * Handles request: stores sync updates and awareness data, and returns + * updates the client is missing. * - * @param string $entity_kind The entity kind. - * @param string $entity_name The entity name. - * @param int|null $object_id The object ID (if applicable). - * @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. - if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { - return current_user_can( 'edit_post', absint( $object_id ) ); - } - - // All of the remaining checks for for collections. If an object ID is - // provided, reject the request. - if ( null !== $object_id ) { - return false; - } - - // 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. - $allowed_collection_entity_kinds = array( - 'postType', - 'root', - 'taxonomy', - ); - - return in_array( $entity_kind, $allowed_collection_entity_kinds, true ); - } - - /** - * Handle request: store sync updates and awareness data, and return updates - * the client is missing. + * @since 7.0.0 * * @param WP_REST_Request $request The REST request. * @return WP_REST_Response|WP_Error Response object or error. */ public function handle_request( WP_REST_Request $request ) { - $rooms = $request->get_param( 'rooms' ); + $rooms = $request['rooms']; $response = array( 'rooms' => array(), ); @@ -239,7 +243,7 @@ public function handle_request( WP_REST_Request $request ) { } // Get updates for this client. - $room_response = $this->get_updates_after( $room, $client_id, $cursor, $is_compactor ); + $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor ); $room_response['awareness'] = $merged_awareness; $response['rooms'][] = $room_response; @@ -249,15 +253,46 @@ public function handle_request( WP_REST_Request $request ) { } /** - * Process and store an awareness update from a client. + * Checks if the current user can sync a specific entity type. * - * @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. + * @param string $entity_kind The entity kind. + * @param string $entity_name The entity name. + * @param string|null $object_id The object ID (if applicable). + * @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. + if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { + return current_user_can( 'edit_post', absint( $object_id ) ); + } + + // All of the remaining checks are for collections. If an object ID is + // provided, reject the request. + if ( null !== $object_id ) { + return false; + } + + // 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. + $allowed_collection_entity_kinds = array( + 'postType', + 'root', + 'taxonomy', + ); + + return in_array( $entity_kind, $allowed_collection_entity_kinds, true ); + } + + /** + * Processes and stores an awareness update from a client. + * + * @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. */ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array { - // Get existing awareness state and filter out expired clients. $existing_awareness = $this->storage->get_awareness_state( $room ); $updated_awareness = array(); $current_time = time(); @@ -285,7 +320,6 @@ private function process_awareness_update( string $room, int $client_id, ?array ); } - // Save updated awareness state. $this->storage->set_awareness_state( $room, $updated_awareness ); // Convert to client_id => state map for response. @@ -298,12 +332,12 @@ private function process_awareness_update( string $room, int $client_id, ?array } /** - * Process a sync update based on its type. + * 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. + * @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. */ private function process_sync_update( string $room, int $client_id, int $cursor, array $update ): void { $data = $update['data']; @@ -311,39 +345,49 @@ private function process_sync_update( string $room, int $client_id, int $cursor, switch ( $type ) { case self::UPDATE_TYPE_COMPACTION: - // Compaction replaces updates the client has already seen. Only remove - // updates with markers before the client's cursor to preserve updates - // that arrived since the client's last sync. - // - // The `remove_updates_before_compaction` method returns false if there - // is a newer compaction update already stored. - if ( $this->remove_updates_before_cursor( $room, $cursor ) ) { + /* + * Compaction replaces updates the client has already seen. Only remove + * updates with markers before the client's cursor to preserve updates + * that arrived since the client's last sync. + * + * Check for a newer compaction update first. If one exists, skip this + * compaction to avoid overwriting it. + */ + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); + $has_newer_compaction = false; + + foreach ( $updates_after_cursor as $existing ) { + if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) { + $has_newer_compaction = true; + break; + } + } + + if ( ! $has_newer_compaction ) { + $this->storage->remove_updates_before_cursor( $room, $cursor ); $this->add_update( $room, $client_id, $type, $data ); } break; case self::UPDATE_TYPE_SYNC_STEP1: - // Sync step 1 announces a client's state vector. Other clients need - // to see it so they can respond with sync_step2 containing missing - // updates. The cursor-based filtering prevents re-delivery. - $this->add_update( $room, $client_id, $type, $data ); - break; - case self::UPDATE_TYPE_SYNC_STEP2: - // Sync step 2 contains updates for a specific client. - // Store it but mark for single delivery (will be cleaned up after delivery). - $this->add_update( $room, $client_id, $type, $data ); - break; - case self::UPDATE_TYPE_UPDATE: - // Regular document updates are stored persistently. + /* + * Sync step 1 announces a client's state vector. Other clients need + * to see it so they can respond with sync_step2 containing missing + * updates. The cursor-based filtering prevents re-delivery. + * + * Sync step 2 contains updates for a specific client. + * + * All updates are stored persistently. + */ $this->add_update( $room, $client_id, $type, $data ); break; } } /** - * Add an update to a room's update list. + * Adds an update to a room's update list via storage. * * @param string $room Room identifier. * @param int $client_id Client identifier. @@ -351,64 +395,38 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * @param string $data Base64-encoded update data. */ private function add_update( string $room, int $client_id, string $type, string $data ): void { - $update_envelope = array( + $update = array( 'client_id' => $client_id, - 'type' => $type, 'data' => $data, - 'timestamp' => $this->get_time_marker(), + 'type' => $type, ); - // Store the update - $this->storage->add_update( $room, $update_envelope ); + $this->storage->add_update( $room, $update ); } /** - * Get the current time in milliseconds as a comparable time marker. + * Gets sync updates for a specific client from a room after a given cursor. * - * @return int Current time in milliseconds. - */ - private function get_time_marker(): int { - return floor( microtime( true ) * 1000 ); - } - - /** - * Get sync updates from a room after a given cursor. + * Delegates cursor-based retrieval to the storage layer, then applies + * client-specific filtering and compaction logic. * * @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 + * @return array Response data for this room. */ - private function get_updates_after( string $room, int $client_id, int $cursor, bool $is_compactor ): array { - $end_cursor = $this->get_time_marker() - 100; // Small buffer to ensure consistency - $all_updates = $this->storage->get_all_updates( $room ); - $total_updates = count( $all_updates ); - $updates = array(); - - foreach ( $all_updates as $update ) { - // Skip updates from this client, unless they are compaction updates. + 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 ); + $total_updates = $this->storage->get_update_count( $room ); + + // Filter out this client's updates, except compaction updates. + $typed_updates = array(); + foreach ( $updates_after_cursor as $update ) { if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) { continue; } - // Skip updates before our cursor. - if ( $update['timestamp'] > $cursor ) { - $updates[] = $update; - } - } - - // Sort by update timestamp to ensure order - usort( - $updates, - function ( $a, $b ) { - return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); - } - ); - - // Convert to typed update format for response. - $typed_updates = array(); - foreach ( $updates as $update ) { $typed_updates[] = array( 'data' => $update['data'], 'type' => $update['type'], @@ -418,49 +436,16 @@ function ( $a, $b ) { // Determine if this client should perform compaction. $compaction_request = null; if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { - $compaction_request = $all_updates; + $compaction_request = $updates_after_cursor; } return array( 'compaction_request' => $compaction_request, - 'end_cursor' => $end_cursor, + 'end_cursor' => $this->storage->get_cursor( $room ), 'room' => $room, 'total_updates' => $total_updates, 'updates' => $typed_updates, ); } - - /** - * Remove updates from a room that are older than the given compaction marker. - * - * @param string $room Room identifier. - * @param int $cursor Remove updates with markers < this cursor. - * @return bool True if this compaction is the latest, false if a newer compaction update exists. - */ - private function remove_updates_before_cursor( string $room, int $cursor ): bool { - $all_updates = $this->storage->get_all_updates( $room ); - $this->storage->remove_all_updates( $room ); - - $is_latest_compaction = true; - $updates_to_keep = array(); - - foreach ( $all_updates as $update ) { - if ( $update['timestamp'] >= $cursor ) { - $updates_to_keep[] = $update; - - if ( self::UPDATE_TYPE_COMPACTION === $update['type'] ) { - // If there is already a newer compaction update, return false. - $is_latest_compaction = false; - } - } - } - - // Replace all updates with filtered list. - foreach ( $updates_to_keep as $update ) { - $this->storage->add_update( $room, $update ); - } - - return $is_latest_compaction; - } } } 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 764610b4c9eb2d..af251e3196f001 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 @@ -2,71 +2,88 @@ /** * WP_Sync_Post_Meta_Storage class * - * @package Gutenberg + * @package gutenberg */ if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) ) { /** - * Class that implements an interface for storing and retrieving sync + * 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. * + * @since 7.0.0 + * * @access private - * @internal */ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage { /** - * Post type for sync storage + * Post type for sync storage. + * + * @since 7.0.0 + * @var string + */ + const POST_TYPE = 'wp_sync_storage'; + + /** + * Cache of cursors by room. + * + * @var array */ - const POST_TYPE = 'sync_storage'; + private $room_cursors = array(); /** - * Singleton post ID for storing sync data + * Cache of update counts by room. + * + * @var array + */ + private $room_update_counts = array(); + + /** + * Singleton post ID for storing sync data. * * @var int|null */ - private static $storage_post_id = null; + private static ?int $storage_post_id = null; /** - * Register the custom post type for sync storage. + * Initializer. + * + * @since 7.0.0 */ - public function init(): void { - register_post_type( - self::POST_TYPE, - array( - 'label' => 'Gutenberg Sync Storage', - 'public' => false, - 'publicly_queryable' => false, - 'show_in_menu' => false, - 'show_in_rest' => false, - 'show_ui' => false, - 'supports' => array( 'custom-fields' ), - ) - ); - } + public function init(): void {} /** - * Add a sync update to a given room. + * Adds a sync update to a given room. + * + * @since 7.0.0 * * @param string $room Room identifier. - * @param array $update Sync update. + * @param mixed $update Sync update. */ - public function add_update( string $room, array $update ): void { + public function add_update( string $room, mixed $update ): void { $post_id = $this->get_storage_post_id(); $meta_key = $this->get_room_meta_key( $room ); - add_post_meta( $post_id, $meta_key, $update, false ); + // Create an envelope and stamp each update to enable cursor-based filtering. + $envelope = array( + 'timestamp' => $this->get_time_marker(), + 'value' => $update, + ); + + add_post_meta( $post_id, $meta_key, $envelope, false ); } /** - * Retrieve sync updates for a given room. + * Retrieve all sync updates for a given room. * * @param string $room Room identifier. - * @return array Array of sync updates. + * @return array Array of sync updates. */ - public function get_all_updates( string $room ): array { + 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 ); @@ -75,24 +92,18 @@ public function get_all_updates( string $room ): array { $updates = array(); } + $this->room_update_counts[ $room ] = count( $updates ); + return $updates; } /** - * Get the meta key for a room's awareness state. + * Gets awareness state for a given room. * - * @param string $room Room identifier. - * @return string Meta key. - */ - private function get_awareness_meta_key( string $room ): string { - return 'sync_awareness_' . md5( $room ); - } - - /** - * Get awareness state for a given room. + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Merged awarenessstate. + * @return array Awareness state. */ public function get_awareness_state( string $room ): array { $post_id = $this->get_storage_post_id(); @@ -107,51 +118,94 @@ public function get_awareness_state( string $room ): array { } /** - * Get the meta key for a room's updates. + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. + */ + public function set_awareness_state( string $room, mixed $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 ); + } + + /** + * 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 ); + } + + /** + * Gets the current cursor for a given room. + * + * The cursor is set during get_updates_after_cursor() and represents the + * point in time just before the updates were retrieved, with a small buffer + * to ensure consistency. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Current cursor for the room. + */ + public function get_cursor( string $room ): int { + return $this->room_cursors[ $room ] ?? 0; + } + + /** + * Gets the meta key for a room's updates. * * @param string $room Room identifier. * @return string Meta key. */ private function get_room_meta_key( string $room ): string { - return 'sync_update_' . md5( $room ); + return 'wp_sync_update_' . md5( $room ); } /** - * Get or create the singleton post for storing sync data. + * Gets or creates the singleton post for storing sync data. * - * @return int Post ID. + * @return int|null Post ID. */ - private function get_storage_post_id(): int { + private function get_storage_post_id(): ?int { if ( is_int( self::$storage_post_id ) ) { return self::$storage_post_id; } - // Try to find existing post + // Try to find existing post. $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'posts_per_page' => 1, 'post_status' => 'publish', - 'orderby' => 'ID', + 'fields' => 'ids', 'order' => 'ASC', ) ); - if ( ! empty( $posts ) ) { - self::$storage_post_id = $posts[0]->ID; + // array_first not introduced until WP 6.9 + $post_id = $posts[0] ?? null; + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_id; return self::$storage_post_id; } - // Create new post if none exists + // Create new post if none exists. $post_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', - 'post_title' => 'Gutenberg Sync Storage', + 'post_title' => 'Sync Storage', ) ); - if ( ! is_wp_error( $post_id ) ) { + if ( is_int( $post_id ) ) { self::$storage_post_id = $post_id; } @@ -159,11 +213,63 @@ private function get_storage_post_id(): int { } /** - * Remove all sync updates for a given room. + * Gets the current time in milliseconds as a comparable time marker. + * + * @return int Current time in milliseconds. + */ + private function get_time_marker(): int { + return floor( microtime( true ) * 1000 ); + } + + /** + * Gets the number of updates stored for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return int Number of updates stored for the room. + */ + public function get_update_count( string $room ): int { + return $this->room_update_counts[ $room ] ?? 0; + } + + /** + * Retrieves sync updates from a room for a given client and cursor. Updates + * from the specified client should be excluded. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Array of sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array { + $all_updates = $this->get_all_updates( $room ); + $updates = array(); + + foreach ( $all_updates as $update ) { + if ( $update['timestamp'] > $cursor ) { + $updates[] = $update; + } + } + + // Sort by timestamp to ensure order. + usort( + $updates, + function ( $a, $b ) { + return ( $a['timestamp'] ?? 0 ) <=> ( $b['timestamp'] ?? 0 ); + } + ); + + return wp_list_pluck( $updates, 'value' ); + } + + /** + * Removes all sync updates for a given room. * * @param string $room Room identifier. */ - public function remove_all_updates( string $room ): void { + private function remove_all_updates( string $room ): void { $post_id = $this->get_storage_post_id(); $meta_key = $this->get_room_meta_key( $room ); @@ -171,16 +277,26 @@ public function remove_all_updates( string $room ): void { } /** - * Set awareness state for a given room. + * Removes updates from a room that are older than the given cursor. * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. */ - public function set_awareness_state( string $room, array $awareness ): void { + public function remove_updates_before_cursor( string $room, int $cursor ): void { + $all_updates = $this->get_all_updates( $room ); + $this->remove_all_updates( $room ); + $post_id = $this->get_storage_post_id(); - $meta_key = $this->get_awareness_meta_key( $room ); + $meta_key = $this->get_room_meta_key( $room ); - update_post_meta( $post_id, $meta_key, $awareness ); + // Re-store envelopes directly to avoid double-wrapping by add_update(). + foreach ( $all_updates as $envelope ) { + if ( $envelope['timestamp'] >= $cursor ) { + add_post_meta( $post_id, $meta_key, $envelope, false ); + } + } } } } diff --git a/lib/compat/wordpress-7.0/collaboration.php b/lib/compat/wordpress-7.0/collaboration.php index 318be8530400e7..aff7585da70bd6 100644 --- a/lib/compat/wordpress-7.0/collaboration.php +++ b/lib/compat/wordpress-7.0/collaboration.php @@ -1,6 +1,6 @@ array( + 'name' => __( 'Sync Updates', 'gutenberg' ), + 'singular_name' => __( 'Sync Update', 'gutenberg' ), + ), + 'public' => false, + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'do_not_allow', + 'read_private_posts' => 'do_not_allow', + 'create_posts' => 'do_not_allow', + 'publish_posts' => 'do_not_allow', + 'edit_posts' => 'do_not_allow', + 'edit_others_posts' => 'do_not_allow', + 'edit_published_posts' => 'do_not_allow', + 'delete_posts' => 'do_not_allow', + 'delete_others_posts' => 'do_not_allow', + 'delete_published_posts' => 'do_not_allow', + ), + 'map_meta_cap' => false, + 'publicly_queryable' => false, + 'query_var' => false, + 'rewrite' => false, + 'show_in_menu' => false, + 'show_in_rest' => false, + 'show_ui' => false, + 'supports' => array( 'custom-fields' ), + ) + ); + } + add_action( 'init', 'gutenberg_register_sync_storage_post_type' ); +} +if ( ! function_exists( 'gutenberg_register_collaboration_rest_routes' ) ) { /** * Registers REST API routes for collaborative editing. */ - function gutenberg_rest_api_register_routes_for_collaborative_editing(): void { + function gutenberg_register_collaboration_rest_routes(): void { $sync_storage = new WP_Sync_Post_Meta_Storage(); - $sync_storage->init(); - - $sse_sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); - $sse_sync_server->init(); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server->register_routes(); } - add_action( 'init', 'gutenberg_rest_api_register_routes_for_collaborative_editing' ); + add_action( 'rest_api_init', 'gutenberg_register_collaboration_rest_routes' ); } if ( ! function_exists( 'wp_collaboration_register_meta' ) ) { @@ -38,14 +78,16 @@ function gutenberg_rest_api_crdt_post_meta() { 'auth_callback' => function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool { return user_can( $user_id, 'edit_post', $object_id ); }, - // IMPORTANT: Revisions must be disabled because we always want to preserve - // the latest persisted CRDT document, even when a revision is restored. - // This ensures that we can continue to apply updates to a shared document - // and peers can simply merge the restored revision like any other incoming - // update. - // - // If we want to persist CRDT documents alongisde revisions in the - // future, we should do so in a separate meta key. + /* + * Revisions must be disabled because we always want to preserve + * the latest persisted CRDT document, even when a revision is restored. + * This ensures that we can continue to apply updates to a shared document + * and peers can simply merge the restored revision like any other incoming + * update. + * + * If we want to persist CRDT documents alongside revisions in the + * future, we should do so in a separate meta key. + */ 'revisions_enabled' => false, 'show_in_rest' => true, 'single' => true, @@ -94,7 +136,7 @@ function () use ( $option_name ) { add_action( 'admin_init', 'gutenberg_register_real_time_collaboration_setting' ); /** - * Injects the real-time collaboration setting for the sync package. + * Injects the real-time collaboration setting into a global variable. */ function gutenberg_inject_real_time_collaboration_setting() { if ( get_option( 'enable_real_time_collaboration' ) ) { 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 1e7ca89f6e3660..75951d47bcdf87 100644 --- a/lib/compat/wordpress-7.0/interface-wp-sync-storage.php +++ b/lib/compat/wordpress-7.0/interface-wp-sync-storage.php @@ -2,53 +2,91 @@ /** * WP_Sync_Storage interface * - * @package Gutenberg + * @package gutenberg */ if ( ! interface_exists( 'WP_Sync_Storage' ) ) { interface WP_Sync_Storage { /** - * Initialize the storage mechanism. + * Initializes the storage mechanism. + * + * @since 7.0.0 */ public function init(): void; /** - * Add a sync update to a given room. + * Adds a sync update to a given room. + * + * @since 7.0.0 * * @param string $room Room identifier. - * @param array $update Sync update. + * @param mixed $update Serializable sync update, opaque to the storage implementation. */ - public function add_update( string $room, array $update ): void; + public function add_update( string $room, mixed $update ): void; /** - * Retrieve sync updates for a given room. + * Gets awareness state for a given room. + * + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Array of sync updates. + * @return array Awareness state. */ - public function get_all_updates( string $room ): array; + public function get_awareness_state( string $room ): array; /** - * Get awareness state for a given room. + * Get 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. + * + * @since 7.0.0 * * @param string $room Room identifier. - * @return array Merged awarenessstate. + * @return int Current cursor for the room. */ - public function get_awareness_state( string $room ): array; + public function get_cursor( string $room ): int; /** - * Remove all updates for a given room. + * Gets the total number of stored updates for a given room. + * + * @since 7.0.0 * * @param string $room Room identifier. + * @return int Total number of updates. */ - public function remove_all_updates( string $room ): void; + public function get_update_count( string $room ): int; /** - * Set awareness state for a given room. + * Retrieves sync updates from a room for a given client and cursor. Updates + * from the specified client should be excluded. * - * @param string $room Room identifier. - * @param array $awareness Merged awareness state. + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @return array Array of sync updates. + */ + public function get_updates_after_cursor( string $room, int $cursor ): array; + + /** + * Removes updates from a room that are older than the provided cursor. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param int $cursor Remove updates with markers < this cursor. + */ + public function remove_updates_before_cursor( string $room, int $cursor ): void; + + /** + * Sets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param array $awareness Serializable awareness state. */ public function set_awareness_state( string $room, array $awareness ): void; } From 37b65d943564399f3cdcb836134216f23510ae1f Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 10 Feb 2026 00:41:27 -0500 Subject: [PATCH 7/9] Remove IS_GUTENBERG_PLUGIN checks --- packages/core-data/src/actions.js | 84 ++++--- packages/core-data/src/entities.js | 121 +++++----- packages/core-data/src/private-selectors.ts | 8 +- packages/core-data/src/resolvers.js | 240 +++++++++----------- packages/core-data/src/test/resolvers.js | 2 +- packages/sync/CODE.md | 2 +- 6 files changed, 210 insertions(+), 247 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index cf69369ef2567c..49a24e64756afb 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -340,13 +340,11 @@ export const deleteEntityRecord = await dispatch( removeItems( kind, name, recordId, true ) ); - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( entityConfig.syncConfig ) { - const objectType = `${ kind }/${ name }`; - const objectId = recordId; + if ( entityConfig.syncConfig ) { + const objectType = `${ kind }/${ name }`; + const objectId = recordId; - getSyncManager()?.unload( objectType, objectId ); - } + getSyncManager()?.unload( objectType, objectId ); } } catch ( _error ) { hasError = true; @@ -427,35 +425,33 @@ export const editEntityRecord = return acc; }, {} ), }; - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( entityConfig.syncConfig ) { - const objectType = `${ kind }/${ name }`; - const objectId = recordId; - - // Determine whether this edit should create a new undo level. - // - // In Gutenberg, block changes flow through two callbacks: - // - `onInput`: For transient/in-progress changes (e.g., typing each - // character). These use `isCached: true` and get merged into - // the current undo item. - // - `onChange`: For persistent/completed changes (e.g., formatting - // transforms, block insertions). These use `isCached: false` and - // should create a new undo level. - // - // Additionally, `undoIgnore: true` means the change should not - // affect the undo history at all (e.g., selection-only changes). - const isNewUndoLevel = options.undoIgnore - ? false - : ! options.isCached; - - getSyncManager()?.update( - objectType, - objectId, - editsWithMerges, - LOCAL_EDITOR_ORIGIN, - { isNewUndoLevel } - ); - } + if ( entityConfig.syncConfig ) { + const objectType = `${ kind }/${ name }`; + const objectId = recordId; + + // Determine whether this edit should create a new undo level. + // + // In Gutenberg, block changes flow through two callbacks: + // - `onInput`: For transient/in-progress changes (e.g., typing each + // character). These use `isCached: true` and get merged into + // the current undo item. + // - `onChange`: For persistent/completed changes (e.g., formatting + // transforms, block insertions). These use `isCached: false` and + // should create a new undo level. + // + // Additionally, `undoIgnore: true` means the change should not + // affect the undo history at all (e.g., selection-only changes). + const isNewUndoLevel = options.undoIgnore + ? false + : ! options.isCached; + + getSyncManager()?.update( + objectType, + objectId, + editsWithMerges, + LOCAL_EDITOR_ORIGIN, + { isNewUndoLevel } + ); } if ( ! options.undoIgnore ) { select.getUndoManager().addRecord( @@ -794,16 +790,14 @@ export const saveEntityRecord = true, edits ); - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( entityConfig.syncConfig ) { - getSyncManager()?.update( - `${ kind }/${ name }`, - recordId, - updatedRecord, - LOCAL_EDITOR_ORIGIN, - { isSave: true } - ); - } + if ( entityConfig.syncConfig ) { + getSyncManager()?.update( + `${ kind }/${ name }`, + recordId, + updatedRecord, + LOCAL_EDITOR_ORIGIN, + { isSave: true } + ); } } } catch ( _error ) { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 901b3c34826e83..4f031f3a2f21a7 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -223,11 +223,10 @@ export const rootEntitiesConfig = [ ].map( ( entity ) => { const syncEnabledRootEntities = new Set( [ 'comment' ] ); - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( syncEnabledRootEntities.has( entity.name ) ) { - entity.syncConfig = defaultSyncConfig; - } + if ( syncEnabledRootEntities.has( entity.name ) ) { + entity.syncConfig = defaultSyncConfig; } + return entity; } ); @@ -289,16 +288,14 @@ export const prePersistPostType = ( } // Add meta for persisted CRDT document. - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( persistedRecord ) { - const objectType = `postType/${ name }`; - const objectId = persistedRecord.id; - const meta = getSyncManager()?.createMeta( objectType, objectId ); - newEdits.meta = { - ...edits.meta, - ...meta, - }; - } + if ( persistedRecord ) { + const objectType = `postType/${ name }`; + const objectId = persistedRecord.id; + const meta = getSyncManager()?.createMeta( objectType, objectId ); + newEdits.meta = { + ...edits.meta, + ...meta, + }; } return newEdits; @@ -353,60 +350,54 @@ async function loadPostTypeEntities() { : DEFAULT_ENTITY_KEY, }; - if ( globalThis.IS_GUTENBERG_PLUGIN ) { + /** + * @type {import('@wordpress/sync').SyncConfig} + */ + entity.syncConfig = { /** - * @type {import('@wordpress/sync').SyncConfig} + * Apply changes from the local editor to the local CRDT document so + * that those changes can be synced to other peers (via the provider). + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @return {void} */ - entity.syncConfig = { - /** - * Apply changes from the local editor to the local CRDT document so - * that those changes can be synced to other peers (via the provider). - * - * @param {import('@wordpress/sync').CRDTDoc} crdtDoc - * @param {Partial< import('@wordpress/sync').ObjectData >} changes - * @return {void} - */ - applyChangesToCRDTDoc: ( crdtDoc, changes ) => - applyPostChangesToCRDTDoc( crdtDoc, changes, postType ), + applyChangesToCRDTDoc: ( crdtDoc, changes ) => + applyPostChangesToCRDTDoc( crdtDoc, changes, postType ), - /** - * Create the awareness instance for the entity's CRDT document. - * - * @param {import('@wordpress/sync').CRDTDoc} ydoc - * @param {import('@wordpress/sync').ObjectID} objectId - * @return {import('@wordpress/sync').Awareness} Awareness instance - */ - createAwareness: ( ydoc, objectId ) => { - const kind = 'postType'; - const id = parseInt( objectId, 10 ); - return new PostEditorAwareness( ydoc, kind, name, id ); - }, + /** + * Create the awareness instance for the entity's CRDT document. + * + * @param {import('@wordpress/sync').CRDTDoc} ydoc + * @param {import('@wordpress/sync').ObjectID} objectId + * @return {import('@wordpress/sync').Awareness} Awareness instance + */ + createAwareness: ( ydoc, objectId ) => { + const kind = 'postType'; + const id = parseInt( objectId, 10 ); + return new PostEditorAwareness( ydoc, kind, name, id ); + }, - /** - * Extract changes from a CRDT document that can be used to update the - * local editor state. - * - * @param {import('@wordpress/sync').CRDTDoc} crdtDoc - * @param {import('@wordpress/sync').ObjectData} editedRecord - * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record - */ - getChangesFromCRDTDoc: ( crdtDoc, editedRecord ) => - getPostChangesFromCRDTDoc( - crdtDoc, - editedRecord, - postType - ), + /** + * Extract changes from a CRDT document that can be used to update the + * local editor state. + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {import('@wordpress/sync').ObjectData} editedRecord + * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record + */ + getChangesFromCRDTDoc: ( crdtDoc, editedRecord ) => + getPostChangesFromCRDTDoc( crdtDoc, editedRecord, postType ), - /** - * Sync features supported by the entity. - * - * @type {Record< string, boolean >} - */ - supports: { - crdtPersistence: true, - }, - }; - } + /** + * Sync features supported by the entity. + * + * @type {Record< string, boolean >} + */ + supports: { + crdtPersistence: true, + }, + }; return entity; } ); @@ -433,9 +424,7 @@ async function loadTaxonomyEntities() { supportsPagination: true, }; - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - entity.syncConfig = defaultSyncConfig; - } + entity.syncConfig = defaultSyncConfig; return entity; } ); diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 36517505274e42..f9f2df1972151c 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -40,12 +40,8 @@ type EntityRecordKey = string | number; * @return The undo manager. */ export function getUndoManager( state: State ) { - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - // undoManager is undefined until the first sync-enabled entity is loaded. - return getSyncManager()?.undoManager ?? state.undoManager; - } - - return state.undoManager; + // undoManager is undefined until the first sync-enabled entity is loaded. + return getSyncManager()?.undoManager ?? state.undoManager; } /** diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index dd62a00d8939c2..3efcf923739015 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -156,117 +156,103 @@ export const getEntityRecord = } // Entity supports syncing. - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( - entityConfig.syncConfig && - isNumericID( key ) && - ! query - ) { - const objectType = `${ kind }/${ name }`; - const objectId = key; - - // Use the new transient "read/write" config to compute transients for - // the sync manager. Otherwise these transients are not available - // if / until the record is edited. Use a copy of the record so that - // it does not change the behavior outside this experimental flag. - const recordWithTransients = { ...record }; - Object.entries( entityConfig.transientEdits ?? {} ) - .filter( - ( [ propName, transientConfig ] ) => - undefined === - recordWithTransients[ propName ] && - transientConfig && - 'object' === typeof transientConfig && - 'read' in transientConfig && - 'function' === typeof transientConfig.read - ) - .forEach( ( [ propName, transientConfig ] ) => { - recordWithTransients[ propName ] = - transientConfig.read( recordWithTransients ); - } ); - - // Load the entity record for syncing. Do not await promise. - void getSyncManager()?.load( - entityConfig.syncConfig, - objectType, - objectId, - recordWithTransients, - { - // Handle edits sourced from the sync manager. - editRecord: ( edits, options = {} ) => { - if ( ! Object.keys( edits ).length ) { - return; - } - - dispatch( { - type: 'EDIT_ENTITY_RECORD', - kind, - name, - recordId: key, - edits, - meta: { - undo: undefined, - }, - options, - } ); - }, - // Get the current entity record (with edits) - getEditedRecord: async () => - await resolveSelect.getEditedEntityRecord( - kind, - name, - key - ), - // Refetch the current entity record from the database. - refetchRecord: async () => { - dispatch.receiveEntityRecords( - kind, - name, - await apiFetch( { path, parse: true } ), - query - ); - }, - // Save the current entity record's unsaved edits. - saveRecord: () => { - dispatch.saveEditedEntityRecord( - kind, - name, - key + if ( entityConfig.syncConfig && isNumericID( key ) && ! query ) { + const objectType = `${ kind }/${ name }`; + const objectId = key; + + // Use the new transient "read/write" config to compute transients for + // the sync manager. Otherwise these transients are not available + // if / until the record is edited. Use a copy of the record so that + // it does not change the behavior outside this experimental flag. + const recordWithTransients = { ...record }; + Object.entries( entityConfig.transientEdits ?? {} ) + .filter( + ( [ propName, transientConfig ] ) => + undefined === recordWithTransients[ propName ] && + transientConfig && + 'object' === typeof transientConfig && + 'read' in transientConfig && + 'function' === typeof transientConfig.read + ) + .forEach( ( [ propName, transientConfig ] ) => { + recordWithTransients[ propName ] = + transientConfig.read( recordWithTransients ); + } ); + + // Load the entity record for syncing. Do not await promise. + void getSyncManager()?.load( + entityConfig.syncConfig, + objectType, + objectId, + recordWithTransients, + { + // Handle edits sourced from the sync manager. + editRecord: ( edits, options = {} ) => { + if ( ! Object.keys( edits ).length ) { + return; + } + + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId: key, + edits, + meta: { + undo: undefined, + }, + options, + } ); + }, + // Get the current entity record (with edits) + getEditedRecord: async () => + await resolveSelect.getEditedEntityRecord( + kind, + name, + key + ), + // Refetch the current entity record from the database. + refetchRecord: async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query + ); + }, + // Save the current entity record's unsaved edits. + saveRecord: () => { + dispatch.saveEditedEntityRecord( kind, name, key ); + }, + addUndoMeta: ( ydoc, meta ) => { + const selectionHistory = + getSelectionHistory( ydoc ); + + if ( selectionHistory ) { + meta.set( + 'selectionHistory', + selectionHistory ); - }, - addUndoMeta: ( ydoc, meta ) => { - const selectionHistory = - getSelectionHistory( ydoc ); - - if ( selectionHistory ) { - meta.set( - 'selectionHistory', - selectionHistory - ); - } - }, - restoreUndoMeta: ( ydoc, meta ) => { - const selectionHistory = - meta.get( 'selectionHistory' ); - - if ( selectionHistory ) { - // Because Yjs initiates an undo, we need to - // wait until the content is restored before - // we can update the selection. - // Use setTimeout() to wait until content is - // finished updating, and then set the correct - // selection. - setTimeout( () => { - restoreSelection( - selectionHistory, - ydoc - ); - }, 0 ); - } - }, - } - ); - } + } + }, + restoreUndoMeta: ( ydoc, meta ) => { + const selectionHistory = + meta.get( 'selectionHistory' ); + + if ( selectionHistory ) { + // Because Yjs initiates an undo, we need to + // wait until the content is restored before + // we can update the selection. + // Use setTimeout() to wait until content is + // finished updating, and then set the correct + // selection. + setTimeout( () => { + restoreSelection( selectionHistory, ydoc ); + }, 0 ); + } + }, + } + ); } registry.batch( () => { @@ -462,24 +448,22 @@ export const getEntityRecords = }; } - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( entityConfig.syncConfig && -1 === query.per_page ) { - const objectType = `${ kind }/${ name }`; - getSyncManager()?.loadCollection( - entityConfig.syncConfig, - objectType, - { - refetchRecords: async () => { - dispatch.receiveEntityRecords( - kind, - name, - await apiFetch( { path, parse: true } ), - query - ); - }, - } - ); - } + if ( entityConfig.syncConfig && -1 === query.per_page ) { + const objectType = `${ kind }/${ name }`; + getSyncManager()?.loadCollection( + entityConfig.syncConfig, + objectType, + { + refetchRecords: async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query + ); + }, + } + ); } // If we request fields but the result doesn't contain the fields, diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index defccb26ec9a9d..87aa3ecc0b0183 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -128,7 +128,7 @@ describe( 'getEntityRecord', () => { ); } ); - it( 'loads entity with sync manager when IS_GUTENBERG_PLUGIN is true', async () => { + it( 'loads entity with sync manager', async () => { const POST_RECORD = { id: 1, title: 'Test Post' }; const POST_RESPONSE = { json: () => Promise.resolve( POST_RECORD ), diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 892a65e5b001b9..8568b89ba66f56 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -11,7 +11,7 @@ Relevant docs and discussions: ## Availability -Real-time collaboration is automatically enabled when using the Gutenberg plugin. The `core-data` package checks for `IS_GUTENBERG_PLUGIN` to determine whether entity syncing is available. +Real-time collaboration can be enabled via WordPress settings: Writing > Enable real-time collaboration. ## The data flow From 5ea9b2596054019146ff0fba2a17dd7370d0214b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 13 Feb 2026 10:11:49 -0500 Subject: [PATCH 8/9] Revert "Remove IS_GUTENBERG_PLUGIN checks" This reverts commit 074f8ffcd91bcc7406974d6ed95263b45a1929da. --- packages/core-data/src/actions.js | 84 +++---- packages/core-data/src/entities.js | 121 +++++----- packages/core-data/src/private-selectors.ts | 8 +- packages/core-data/src/resolvers.js | 240 +++++++++++--------- packages/core-data/src/test/resolvers.js | 2 +- packages/sync/CODE.md | 2 +- 6 files changed, 247 insertions(+), 210 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 49a24e64756afb..cf69369ef2567c 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -340,11 +340,13 @@ export const deleteEntityRecord = await dispatch( removeItems( kind, name, recordId, true ) ); - if ( entityConfig.syncConfig ) { - const objectType = `${ kind }/${ name }`; - const objectId = recordId; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( entityConfig.syncConfig ) { + const objectType = `${ kind }/${ name }`; + const objectId = recordId; - getSyncManager()?.unload( objectType, objectId ); + getSyncManager()?.unload( objectType, objectId ); + } } } catch ( _error ) { hasError = true; @@ -425,33 +427,35 @@ export const editEntityRecord = return acc; }, {} ), }; - if ( entityConfig.syncConfig ) { - const objectType = `${ kind }/${ name }`; - const objectId = recordId; - - // Determine whether this edit should create a new undo level. - // - // In Gutenberg, block changes flow through two callbacks: - // - `onInput`: For transient/in-progress changes (e.g., typing each - // character). These use `isCached: true` and get merged into - // the current undo item. - // - `onChange`: For persistent/completed changes (e.g., formatting - // transforms, block insertions). These use `isCached: false` and - // should create a new undo level. - // - // Additionally, `undoIgnore: true` means the change should not - // affect the undo history at all (e.g., selection-only changes). - const isNewUndoLevel = options.undoIgnore - ? false - : ! options.isCached; - - getSyncManager()?.update( - objectType, - objectId, - editsWithMerges, - LOCAL_EDITOR_ORIGIN, - { isNewUndoLevel } - ); + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( entityConfig.syncConfig ) { + const objectType = `${ kind }/${ name }`; + const objectId = recordId; + + // Determine whether this edit should create a new undo level. + // + // In Gutenberg, block changes flow through two callbacks: + // - `onInput`: For transient/in-progress changes (e.g., typing each + // character). These use `isCached: true` and get merged into + // the current undo item. + // - `onChange`: For persistent/completed changes (e.g., formatting + // transforms, block insertions). These use `isCached: false` and + // should create a new undo level. + // + // Additionally, `undoIgnore: true` means the change should not + // affect the undo history at all (e.g., selection-only changes). + const isNewUndoLevel = options.undoIgnore + ? false + : ! options.isCached; + + getSyncManager()?.update( + objectType, + objectId, + editsWithMerges, + LOCAL_EDITOR_ORIGIN, + { isNewUndoLevel } + ); + } } if ( ! options.undoIgnore ) { select.getUndoManager().addRecord( @@ -790,14 +794,16 @@ export const saveEntityRecord = true, edits ); - if ( entityConfig.syncConfig ) { - getSyncManager()?.update( - `${ kind }/${ name }`, - recordId, - updatedRecord, - LOCAL_EDITOR_ORIGIN, - { isSave: true } - ); + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( entityConfig.syncConfig ) { + getSyncManager()?.update( + `${ kind }/${ name }`, + recordId, + updatedRecord, + LOCAL_EDITOR_ORIGIN, + { isSave: true } + ); + } } } } catch ( _error ) { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 4f031f3a2f21a7..901b3c34826e83 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -223,10 +223,11 @@ export const rootEntitiesConfig = [ ].map( ( entity ) => { const syncEnabledRootEntities = new Set( [ 'comment' ] ); - if ( syncEnabledRootEntities.has( entity.name ) ) { - entity.syncConfig = defaultSyncConfig; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( syncEnabledRootEntities.has( entity.name ) ) { + entity.syncConfig = defaultSyncConfig; + } } - return entity; } ); @@ -288,14 +289,16 @@ export const prePersistPostType = ( } // Add meta for persisted CRDT document. - if ( persistedRecord ) { - const objectType = `postType/${ name }`; - const objectId = persistedRecord.id; - const meta = getSyncManager()?.createMeta( objectType, objectId ); - newEdits.meta = { - ...edits.meta, - ...meta, - }; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( persistedRecord ) { + const objectType = `postType/${ name }`; + const objectId = persistedRecord.id; + const meta = getSyncManager()?.createMeta( objectType, objectId ); + newEdits.meta = { + ...edits.meta, + ...meta, + }; + } } return newEdits; @@ -350,54 +353,60 @@ async function loadPostTypeEntities() { : DEFAULT_ENTITY_KEY, }; - /** - * @type {import('@wordpress/sync').SyncConfig} - */ - entity.syncConfig = { + if ( globalThis.IS_GUTENBERG_PLUGIN ) { /** - * Apply changes from the local editor to the local CRDT document so - * that those changes can be synced to other peers (via the provider). - * - * @param {import('@wordpress/sync').CRDTDoc} crdtDoc - * @param {Partial< import('@wordpress/sync').ObjectData >} changes - * @return {void} + * @type {import('@wordpress/sync').SyncConfig} */ - applyChangesToCRDTDoc: ( crdtDoc, changes ) => - applyPostChangesToCRDTDoc( crdtDoc, changes, postType ), + entity.syncConfig = { + /** + * Apply changes from the local editor to the local CRDT document so + * that those changes can be synced to other peers (via the provider). + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @return {void} + */ + applyChangesToCRDTDoc: ( crdtDoc, changes ) => + applyPostChangesToCRDTDoc( crdtDoc, changes, postType ), - /** - * Create the awareness instance for the entity's CRDT document. - * - * @param {import('@wordpress/sync').CRDTDoc} ydoc - * @param {import('@wordpress/sync').ObjectID} objectId - * @return {import('@wordpress/sync').Awareness} Awareness instance - */ - createAwareness: ( ydoc, objectId ) => { - const kind = 'postType'; - const id = parseInt( objectId, 10 ); - return new PostEditorAwareness( ydoc, kind, name, id ); - }, + /** + * Create the awareness instance for the entity's CRDT document. + * + * @param {import('@wordpress/sync').CRDTDoc} ydoc + * @param {import('@wordpress/sync').ObjectID} objectId + * @return {import('@wordpress/sync').Awareness} Awareness instance + */ + createAwareness: ( ydoc, objectId ) => { + const kind = 'postType'; + const id = parseInt( objectId, 10 ); + return new PostEditorAwareness( ydoc, kind, name, id ); + }, - /** - * Extract changes from a CRDT document that can be used to update the - * local editor state. - * - * @param {import('@wordpress/sync').CRDTDoc} crdtDoc - * @param {import('@wordpress/sync').ObjectData} editedRecord - * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record - */ - getChangesFromCRDTDoc: ( crdtDoc, editedRecord ) => - getPostChangesFromCRDTDoc( crdtDoc, editedRecord, postType ), + /** + * Extract changes from a CRDT document that can be used to update the + * local editor state. + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {import('@wordpress/sync').ObjectData} editedRecord + * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record + */ + getChangesFromCRDTDoc: ( crdtDoc, editedRecord ) => + getPostChangesFromCRDTDoc( + crdtDoc, + editedRecord, + postType + ), - /** - * Sync features supported by the entity. - * - * @type {Record< string, boolean >} - */ - supports: { - crdtPersistence: true, - }, - }; + /** + * Sync features supported by the entity. + * + * @type {Record< string, boolean >} + */ + supports: { + crdtPersistence: true, + }, + }; + } return entity; } ); @@ -424,7 +433,9 @@ async function loadTaxonomyEntities() { supportsPagination: true, }; - entity.syncConfig = defaultSyncConfig; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + entity.syncConfig = defaultSyncConfig; + } return entity; } ); diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index f9f2df1972151c..36517505274e42 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -40,8 +40,12 @@ type EntityRecordKey = string | number; * @return The undo manager. */ export function getUndoManager( state: State ) { - // undoManager is undefined until the first sync-enabled entity is loaded. - return getSyncManager()?.undoManager ?? state.undoManager; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + // undoManager is undefined until the first sync-enabled entity is loaded. + return getSyncManager()?.undoManager ?? state.undoManager; + } + + return state.undoManager; } /** diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 3efcf923739015..dd62a00d8939c2 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -156,103 +156,117 @@ export const getEntityRecord = } // Entity supports syncing. - if ( entityConfig.syncConfig && isNumericID( key ) && ! query ) { - const objectType = `${ kind }/${ name }`; - const objectId = key; - - // Use the new transient "read/write" config to compute transients for - // the sync manager. Otherwise these transients are not available - // if / until the record is edited. Use a copy of the record so that - // it does not change the behavior outside this experimental flag. - const recordWithTransients = { ...record }; - Object.entries( entityConfig.transientEdits ?? {} ) - .filter( - ( [ propName, transientConfig ] ) => - undefined === recordWithTransients[ propName ] && - transientConfig && - 'object' === typeof transientConfig && - 'read' in transientConfig && - 'function' === typeof transientConfig.read - ) - .forEach( ( [ propName, transientConfig ] ) => { - recordWithTransients[ propName ] = - transientConfig.read( recordWithTransients ); - } ); - - // Load the entity record for syncing. Do not await promise. - void getSyncManager()?.load( - entityConfig.syncConfig, - objectType, - objectId, - recordWithTransients, - { - // Handle edits sourced from the sync manager. - editRecord: ( edits, options = {} ) => { - if ( ! Object.keys( edits ).length ) { - return; - } - - dispatch( { - type: 'EDIT_ENTITY_RECORD', - kind, - name, - recordId: key, - edits, - meta: { - undo: undefined, - }, - options, - } ); - }, - // Get the current entity record (with edits) - getEditedRecord: async () => - await resolveSelect.getEditedEntityRecord( - kind, - name, - key - ), - // Refetch the current entity record from the database. - refetchRecord: async () => { - dispatch.receiveEntityRecords( - kind, - name, - await apiFetch( { path, parse: true } ), - query - ); - }, - // Save the current entity record's unsaved edits. - saveRecord: () => { - dispatch.saveEditedEntityRecord( kind, name, key ); - }, - addUndoMeta: ( ydoc, meta ) => { - const selectionHistory = - getSelectionHistory( ydoc ); - - if ( selectionHistory ) { - meta.set( - 'selectionHistory', - selectionHistory + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( + entityConfig.syncConfig && + isNumericID( key ) && + ! query + ) { + const objectType = `${ kind }/${ name }`; + const objectId = key; + + // Use the new transient "read/write" config to compute transients for + // the sync manager. Otherwise these transients are not available + // if / until the record is edited. Use a copy of the record so that + // it does not change the behavior outside this experimental flag. + const recordWithTransients = { ...record }; + Object.entries( entityConfig.transientEdits ?? {} ) + .filter( + ( [ propName, transientConfig ] ) => + undefined === + recordWithTransients[ propName ] && + transientConfig && + 'object' === typeof transientConfig && + 'read' in transientConfig && + 'function' === typeof transientConfig.read + ) + .forEach( ( [ propName, transientConfig ] ) => { + recordWithTransients[ propName ] = + transientConfig.read( recordWithTransients ); + } ); + + // Load the entity record for syncing. Do not await promise. + void getSyncManager()?.load( + entityConfig.syncConfig, + objectType, + objectId, + recordWithTransients, + { + // Handle edits sourced from the sync manager. + editRecord: ( edits, options = {} ) => { + if ( ! Object.keys( edits ).length ) { + return; + } + + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId: key, + edits, + meta: { + undo: undefined, + }, + options, + } ); + }, + // Get the current entity record (with edits) + getEditedRecord: async () => + await resolveSelect.getEditedEntityRecord( + kind, + name, + key + ), + // Refetch the current entity record from the database. + refetchRecord: async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query ); - } - }, - restoreUndoMeta: ( ydoc, meta ) => { - const selectionHistory = - meta.get( 'selectionHistory' ); - - if ( selectionHistory ) { - // Because Yjs initiates an undo, we need to - // wait until the content is restored before - // we can update the selection. - // Use setTimeout() to wait until content is - // finished updating, and then set the correct - // selection. - setTimeout( () => { - restoreSelection( selectionHistory, ydoc ); - }, 0 ); - } - }, - } - ); + }, + // Save the current entity record's unsaved edits. + saveRecord: () => { + dispatch.saveEditedEntityRecord( + kind, + name, + key + ); + }, + addUndoMeta: ( ydoc, meta ) => { + const selectionHistory = + getSelectionHistory( ydoc ); + + if ( selectionHistory ) { + meta.set( + 'selectionHistory', + selectionHistory + ); + } + }, + restoreUndoMeta: ( ydoc, meta ) => { + const selectionHistory = + meta.get( 'selectionHistory' ); + + if ( selectionHistory ) { + // Because Yjs initiates an undo, we need to + // wait until the content is restored before + // we can update the selection. + // Use setTimeout() to wait until content is + // finished updating, and then set the correct + // selection. + setTimeout( () => { + restoreSelection( + selectionHistory, + ydoc + ); + }, 0 ); + } + }, + } + ); + } } registry.batch( () => { @@ -448,22 +462,24 @@ export const getEntityRecords = }; } - if ( entityConfig.syncConfig && -1 === query.per_page ) { - const objectType = `${ kind }/${ name }`; - getSyncManager()?.loadCollection( - entityConfig.syncConfig, - objectType, - { - refetchRecords: async () => { - dispatch.receiveEntityRecords( - kind, - name, - await apiFetch( { path, parse: true } ), - query - ); - }, - } - ); + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + if ( entityConfig.syncConfig && -1 === query.per_page ) { + const objectType = `${ kind }/${ name }`; + getSyncManager()?.loadCollection( + entityConfig.syncConfig, + objectType, + { + refetchRecords: async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query + ); + }, + } + ); + } } // If we request fields but the result doesn't contain the fields, diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 87aa3ecc0b0183..defccb26ec9a9d 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -128,7 +128,7 @@ describe( 'getEntityRecord', () => { ); } ); - it( 'loads entity with sync manager', async () => { + it( 'loads entity with sync manager when IS_GUTENBERG_PLUGIN is true', async () => { const POST_RECORD = { id: 1, title: 'Test Post' }; const POST_RESPONSE = { json: () => Promise.resolve( POST_RECORD ), diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 8568b89ba66f56..892a65e5b001b9 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -11,7 +11,7 @@ Relevant docs and discussions: ## Availability -Real-time collaboration can be enabled via WordPress settings: Writing > Enable real-time collaboration. +Real-time collaboration is automatically enabled when using the Gutenberg plugin. The `core-data` package checks for `IS_GUTENBERG_PLUGIN` to determine whether entity syncing is available. ## The data flow From 3b2599562de35803cfc8e6486400fb1836802783 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 13 Feb 2026 10:53:41 -0500 Subject: [PATCH 9/9] Update type hint --- lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 af251e3196f001..1a6596656ea7e7 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 @@ -125,7 +125,7 @@ public function get_awareness_state( string $room ): array { * @param string $room Room identifier. * @param array $awareness Serializable awareness state. */ - public function set_awareness_state( string $room, mixed $awareness ): void { + 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 );