diff --git a/package.json b/package.json index 766e241ff8d6d..7be6ab6f2bd6b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "ref": "59a08c5496008ca88f4b6b86f38838c3612d88c8" + "ref": "2ab68995bfe6f038d0d0851ad2023007d04730f7" }, "engines": { "node": ">=20.10.0", diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 795060f28b83e..911314ea2cbf3 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -563,6 +563,9 @@ function populate_options( array $options = array() ) { // 6.9.0 'wp_notes_notify' => 1, + + // 7.0.0 + 'enable_real_time_collaboration' => 0, ); // 3.3.0 diff --git a/src/wp-admin/options-writing.php b/src/wp-admin/options-writing.php index 6f85b54679327..f535ec7a092ab 100644 --- a/src/wp-admin/options-writing.php +++ b/src/wp-admin/options-writing.php @@ -109,6 +109,13 @@ + + + + /> + + + diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php index 8db5cf50f2ec9..57c22be86d367 100644 --- a/src/wp-admin/options.php +++ b/src/wp-admin/options.php @@ -153,6 +153,7 @@ 'default_email_category', 'default_link_category', 'default_post_format', + 'enable_real_time_collaboration', ), ); $allowed_options['misc'] = array(); diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php new file mode 100644 index 0000000000000..51fb45bd0efff --- /dev/null +++ b/src/wp-includes/collaboration.php @@ -0,0 +1,24 @@ +storage = $storage; + } + + /** + * Registers REST API routes. + * + * @since 7.0.0 + */ + 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', + ); + + $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', + ), + ); + + 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', + ), + ), + ) + ); + } + + /** + * 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['rooms']; + + foreach ( $rooms as $room ) { + $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 ) + ); + } + + $entity_kind = $type_parts[0]; + $entity_name = $object_parts[0]; + $object_id = $object_parts[1] ?? null; + + if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) { + return new WP_Error( + 'forbidden', + sprintf( + /* translators: %s: The room name encodes the current entity being synced. */ + __( 'You do not have permission to sync this entity: %s.' ), + $room + ), + array( 'status' => 401 ) + ); + } + } + + return true; + } + + /** + * Handles request: stores sync updates and awareness data, and returns + * 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['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 ); + } + + // Get updates for this client. + $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor ); + $room_response['awareness'] = $merged_awareness; + + $response['rooms'][] = $room_response; + } + + return new WP_REST_Response( $response, 200 ); + } + + /** + * Checks if the current user can sync a specific entity type. + * + * @param string $entity_kind The entity kind. + * @param string $entity_name The entity name. + * @param string|null $object_id The object ID (if applicable). + * @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 { + $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; + } + + // Add this client's awareness state. + if ( null !== $awareness_update ) { + $updated_awareness[] = array( + 'client_id' => $client_id, + 'state' => $awareness_update, + 'updated_at' => $current_time, + ); + } + + $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']; + } + + return $response; + } + + /** + * 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. + */ + 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. + * + * 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: + case self::UPDATE_TYPE_SYNC_STEP2: + case self::UPDATE_TYPE_UPDATE: + /* + * 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; + } + } + + /** + * Adds an update to a room's update list via storage. + * + * @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 = array( + 'client_id' => $client_id, + 'data' => $data, + 'type' => $type, + ); + + $this->storage->add_update( $room, $update ); + } + + /** + * Gets sync updates for a specific client 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 Response data for this room. + */ + private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); + $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; + } + + $typed_updates[] = array( + 'data' => $update['data'], + 'type' => $update['type'], + ); + } + + // Determine if this client should perform compaction. + $compaction_request = null; + if ( $is_compactor && $total_updates > self::COMPACTION_THRESHOLD ) { + $compaction_request = $updates_after_cursor; + } + + return array( + 'compaction_request' => $compaction_request, + 'end_cursor' => $this->storage->get_cursor( $room ), + 'room' => $room, + 'total_updates' => $total_updates, + 'updates' => $typed_updates, + ); + } +} diff --git a/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php new file mode 100644 index 0000000000000..fa9463fe60297 --- /dev/null +++ b/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php @@ -0,0 +1,298 @@ + + */ + private $room_cursors = array(); + + /** + * 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 ?int $storage_post_id = null; + + /** + * Initializer. + * + * @since 7.0.0 + */ + public function init(): void {} + + /** + * Adds a sync update to a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @param mixed $update Sync update. + */ + public function add_update( string $room, mixed $update ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + // 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 all sync updates for a given room. + * + * @param string $room Room identifier. + * @return array Array of sync updates. + */ + private function get_all_updates( string $room ): array { + $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. + + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + $updates = get_post_meta( $post_id, $meta_key, false ); + + if ( ! is_array( $updates ) ) { + $updates = array(); + } + + $this->room_update_counts[ $room ] = count( $updates ); + + return $updates; + } + + /** + * Gets awareness state for a given room. + * + * @since 7.0.0 + * + * @param string $room Room identifier. + * @return array Awareness state. + */ + public function get_awareness_state( string $room ): array { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_awareness_meta_key( $room ); + $awareness = get_post_meta( $post_id, $meta_key, true ); + + if ( ! is_array( $awareness ) ) { + return array(); + } + + return $awareness; + } + + /** + * 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 { + $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 'wp_sync_update_' . md5( $room ); + } + + /** + * Gets or creates the singleton post for storing sync data. + * + * @return int|null 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', + 'fields' => 'ids', + 'order' => 'ASC', + ) + ); + + $post_id = array_first( $posts ); + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_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' => 'Sync Storage', + ) + ); + + if ( is_int( $post_id ) ) { + self::$storage_post_id = $post_id; + } + + return self::$storage_post_id; + } + + /** + * 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. + */ + private function remove_all_updates( string $room ): void { + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + delete_post_meta( $post_id, $meta_key ); + } + + /** + * Removes updates from a room that are older than the given cursor. + * + * @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 { + $all_updates = $this->get_all_updates( $room ); + $this->remove_all_updates( $room ); + + $post_id = $this->get_storage_post_id(); + $meta_key = $this->get_room_meta_key( $room ); + + // 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/src/wp-includes/collaboration/interface-wp-sync-storage.php b/src/wp-includes/collaboration/interface-wp-sync-storage.php new file mode 100644 index 0000000000000..9687bfb33fc8e --- /dev/null +++ b/src/wp-includes/collaboration/interface-wp-sync-storage.php @@ -0,0 +1,90 @@ + Awareness state. + */ + public function get_awareness_state( string $room ): array; + + /** + * 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 int Current cursor for the room. + */ + public function get_cursor( string $room ): int; + + /** + * 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 get_update_count( string $room ): int; + + /** + * 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; + + /** + * 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; +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index de0b374ef4b56..98cd8ddd127ec 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -785,6 +785,9 @@ add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); add_action( 'init', '_wp_register_default_font_collections' ); +// Collaboration. +add_action( 'admin_init', 'wp_collaboration_inject_setting' ); + // Add ignoredHookedBlocks metadata attribute to the template and template part post types. add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' ); add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' ); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 8a9a2c3c89ece..22695444cde35 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2897,6 +2897,18 @@ function register_initial_settings() { ) ); + register_setting( + 'writing', + 'enable_real_time_collaboration', + array( + 'type' => 'boolean', + 'description' => __( 'Enable Real-Time Collaboration' ), + 'sanitize_callback' => 'rest_sanitize_boolean', + 'default' => false, + 'show_in_rest' => true, + ) + ); + register_setting( 'reading', 'posts_per_page', diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 8ccc4bb36df2a..8b0e4d302e9fb 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -657,6 +657,39 @@ function create_initial_post_types() { ) ); + register_post_type( + 'wp_sync_storage', + array( + 'labels' => array( + 'name' => __( 'Sync Updates' ), + 'singular_name' => __( 'Sync Update' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + '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' ), + ) + ); + register_post_status( 'publish', array( @@ -8611,6 +8644,7 @@ function use_block_editor_for_post_type( $post_type ) { * Registers any additional post meta fields. * * @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added. + * @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted. * * @link https://github.com/WordPress/gutenberg/pull/51144 */ @@ -8630,4 +8664,28 @@ function wp_create_initial_post_meta() { ), ) ); + + register_meta( + 'post', + '_crdt_document', + 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 ); + }, + /* + * 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, + 'type' => 'string', + ) + ); } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c4fce5a43e7d8..76241d470b4a2 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -424,6 +424,11 @@ function create_initial_rest_routes() { $abilities_run_controller->register_routes(); $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller(); $abilities_list_controller->register_routes(); + + // Collaboration. + $sync_storage = new WP_Sync_Post_Meta_Storage(); + $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage ); + $sync_server->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 8cb0a4987ff3d..b47a614c873d6 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -232,7 +232,31 @@ public function create_item( $request ) { $post_lock = wp_check_post_lock( $post->ID ); $is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status; - if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) { + /* + * In the context of real-time collaboration, all peers are effectively + * authors and we don't want to vary behavior based on whether they are the + * original author. Always target an autosave revision. + * + * This avoids the following issue when real-time collaboration is enabled: + * + * - Autosaves from the original author (if they have the post lock) will + * target the saved post. + * + * - Autosaves from other users are applied to a post revision. + * + * - If any user reloads a post, they load changes from the author's autosave. + * + * - The saved post has now diverged from the persisted CRDT document. The + * content (and/or title or excerpt) are now "ahead" of the persisted CRDT + * document. + * + * - When the persisted CRDT document is loaded, a diff is computed against + * the saved post. This diff is then applied to the in-memory CRDT + * document, which can lead to duplicate inserts or deletions. + */ + $is_collaboration_enabled = get_option( 'enable_real_time_collaboration' ); + + if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) { /* * Draft posts for the same author: autosaving updates the post and does not create a revision. * Convert the post object to an array and add slashes, wp_update_post() expects escaped array. diff --git a/src/wp-settings.php b/src/wp-settings.php index 60c220100f539..f1a475c0eb093 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -299,6 +299,10 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/abilities.php'; +require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php'; +require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php'; +require ABSPATH . WPINC . '/collaboration.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index e8f90b53f20f1..eee2605531cc9 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -119,6 +119,7 @@ public function test_get_items() { 'default_ping_status', 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php + 'enable_real_time_collaboration', ); if ( ! is_multisite() ) {